package dev.privacyllc.period.notifications import androidx.test.core.app.ApplicationProvider import dev.privacyllc.period.core.data.CycleData import dev.privacyllc.period.core.data.CycleRepository import dev.privacyllc.period.core.data.PeriodWriteResult import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.notifications.NotificationCopy import dev.privacyllc.period.core.notifications.ReminderAction import dev.privacyllc.period.core.notifications.ReminderActionRequest import dev.privacyllc.period.core.notifications.ReminderKind import dev.privacyllc.period.core.datastore.NotificationPrivacy import dev.privacyllc.period.domain.cycle.PeriodRecordSource import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine import androidx.datastore.preferences.core.PreferenceDataStoreFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.time.Clock import java.time.LocalDate import java.time.ZoneOffset /** * The two writes reachable from a locked phone. * * These had no test at all, which is how the period-end check-in came to record * the opposite of what its buttons said: "Ended" inserted a new period starting * today, in the middle of the period it was asking about. The button labels are * chosen in one file and the writes happened in another, and nothing held them * to each other. * * So the first test here is not about a write at all โ€” it is about the two * halves agreeing. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class NotificationActionHandlerTest { @get:Rule val temp = TemporaryFolder() private lateinit var repo: CycleRepository private lateinit var prefs: UserPreferencesRepository private lateinit var handler: NotificationActionHandler private val today = LocalDate.of(2026, 8, 20) @Before fun open() { val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC) // Through CycleData, like every other app test: this module deliberately // cannot name PeriodDatabase, which is the boundary architecture/README // describes and the compiler enforces. repo = CycleData.repository(ApplicationProvider.getApplicationContext(), PersonalPredictionEngine(), clock) runBlocking { repo.deleteAllHealthData() } prefs = UserPreferencesRepository( PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.Unconfined)) { temp.newFile("prefs.preferences_pb") }, ) handler = NotificationActionHandler(repo, prefs, clock) } // ----------------------------------------------------------------------- // The pairing itself // ----------------------------------------------------------------------- @Test fun `a question about the end never offers an answer about the start`() { // The defect, stated as a property. Whatever the labels say in whatever // privacy mode, the period-end check-in must not be able to produce a // start, and the did-it-start family must not be able to produce an end. NotificationPrivacy.entries.forEach { privacy -> val end = NotificationCopy.buttons(ReminderKind.PERIOD_END_CHECK_IN, privacy) assertEquals(listOf(ReminderAction.ENDED, ReminderAction.STILL_GOING), end.map { it.action }) listOf(ReminderKind.DID_IT_START, ReminderKind.PERIOD_EXPECTED_TODAY).forEach { kind -> val start = NotificationCopy.buttons(kind, privacy) assertEquals(listOf(ReminderAction.STARTED, ReminderAction.NOT_YET), start.map { it.action }) } (end + NotificationCopy.buttons(ReminderKind.DID_IT_START, privacy)).forEach { assertTrue("a button in $privacy had a blank label", it.label.isNotBlank()) } } } // ----------------------------------------------------------------------- // What each button writes // ----------------------------------------------------------------------- @Test fun `Started records a period, sourced from the notification`() = runTest { assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today))) val period = repo.confirmedPeriods.first().single() assertEquals(today, period.startDate) // Not MANUAL: how a record arrived is part of the record (ยง14). assertEquals(PeriodRecordSource.NOTIFICATION_CONFIRMATION, period.source) } @Test fun `Not yet records a censoring observation and no period`() = runTest { assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today))) assertEquals(today, repo.notYetObservations.first().single().date) assertTrue(repo.confirmedPeriods.first().isEmpty()) } @Test fun `Ended closes the period that is running rather than starting another`() = runTest { repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today))) // Regression: this used to insert a SECOND period starting today, in the // middle of the one the notification was asking about. val period = repo.confirmedPeriods.first().single() assertEquals(LocalDate.of(2026, 8, 16), period.startDate) assertEquals(today, period.endDate) } @Test fun `Ended with nothing running writes nothing`() = runTest { assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today))) assertTrue(repo.confirmedPeriods.first().isEmpty()) } @Test fun `Ended leaves a period that was already closed alone`() = runTest { val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) as PeriodWriteResult.Added).id repo.setPeriodEnd(id, LocalDate.of(2026, 8, 19)) assertTrue(handler.handle(ReminderActionRequest(ReminderAction.ENDED, today))) // She answered in the app first. The honest response to an answered // question is silence, not a correction. assertEquals(LocalDate.of(2026, 8, 19), repo.confirmedPeriods.first().single().endDate) } @Test fun `Still going writes nothing at all`() = runTest { repo.confirmPeriodStart(LocalDate.of(2026, 8, 16)) val before = repo.confirmedPeriods.first().single() assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STILL_GOING, today))) val after = repo.confirmedPeriods.first().single() assertEquals(before, after) assertNull(after.endDate) assertTrue(repo.notYetObservations.first().isEmpty()) } // ----------------------------------------------------------------------- // Anything else // ----------------------------------------------------------------------- @Test fun `an action this app did not write is not guessed at`() = runTest { // Nothing to apply at all. assertFalse(handler.handle(null)) // And a notification posted before the day travelled with the answer โ€” // one sitting in somebody's shade across an upgrade. It opens the app // and writes nothing, because there is no day it could honestly use. assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, date = null))) assertTrue(repo.confirmedPeriods.first().isEmpty()) assertTrue(repo.notYetObservations.first().isEmpty()) } // ----------------------------------------------------------------------- // The day it was asking about // ----------------------------------------------------------------------- @Test fun `an answer given the next morning is recorded against the day it was asked`() = runTest { // Posted yesterday evening, tapped this morning. The handler used // LocalDate.now(), so this recorded today โ€” and a start date is the one // input the whole prediction engine is built on. assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.minusDays(1)))) assertEquals(today.minusDays(1), repo.confirmedPeriods.first().single().startDate) } @Test fun `an answer left for days writes nothing`() = runTest { assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.minusDays(5)))) // She has lived days the app knows nothing about. Opening the app, where // she can see and fix what is recorded, beats a confident wrong write. assertTrue(repo.confirmedPeriods.first().isEmpty()) } @Test fun `a question already answered in the app is not answered twice`() = runTest { repo.confirmPeriodStart(today) // The notification from yesterday is still in the shade. assertTrue(handler.handle(ReminderActionRequest(ReminderAction.NOT_YET, today.minusDays(1)))) // A "not yet" for a day before a start she has since logged would // censor a forecast that has already arrived. assertTrue(repo.notYetObservations.first().isEmpty()) assertEquals(1, repo.confirmedPeriods.first().size) } @Test fun `an answer dated in the future writes nothing`() = runTest { assertTrue(handler.handle(ReminderActionRequest(ReminderAction.STARTED, today.plusDays(1)))) assertTrue(repo.confirmedPeriods.first().isEmpty()) } }