diff --git a/app/src/test/java/app/closer/ui/questions/ReconcileLocalAnswerTest.kt b/app/src/test/java/app/closer/ui/questions/ReconcileLocalAnswerTest.kt new file mode 100644 index 00000000..2c06c5e9 --- /dev/null +++ b/app/src/test/java/app/closer/ui/questions/ReconcileLocalAnswerTest.kt @@ -0,0 +1,125 @@ +package app.closer.ui.questions + +import app.closer.data.remote.FirestoreAnswerDataSource +import app.closer.data.remote.FirestoreAnswerDataSource.DecodedAnswer +import app.closer.domain.model.ChoiceAnswerConfig +import app.closer.domain.model.ChoiceAnswerConfigImpl +import app.closer.domain.model.ChoiceOption +import app.closer.domain.model.LocalAnswer +import app.closer.domain.model.Question +import app.closer.domain.repository.LocalAnswerRepository +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit coverage for the R23 data-loss guard: [reconcileLocalAnswerFromFirestore] must heal a + * Room/Firestore desync (fresh device / cleared local DB) so the daily question is never offered + * for a silently-rejected re-answer. + */ +class ReconcileLocalAnswerTest { + + private val coupleId = "couple1" + private val date = "2026-06-30" + private val userId = "userA" + + private val choiceQ = Question( + id = "q1", text = "Snack mission?", category = "fun", type = "single_choice", + answerConfig = ChoiceAnswerConfigImpl( + config = ChoiceAnswerConfig( + options = listOf(ChoiceOption("sweet", "Sweet"), ChoiceOption("salty", "Salty")) + ) + ) + ) + + private fun meta(questionId: String = "q1", revealed: Boolean = false) = LocalAnswer( + questionId = questionId, + questionText = "", + category = "", + answerType = "single_choice", + isRevealed = revealed, + answerDate = date + ) + + @Test + fun `room-first - returns the local answer without touching Firestore`() = runTest { + val local = mockk() + val firestore = mockk() + val existing = meta().copy(selectedOptionIds = listOf("sweet")) + coEvery { local.getAnswer("q1") } returns existing + + val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local) + + assertEquals(existing, result) + coVerify(exactly = 0) { firestore.getAnswerForUser(any(), any(), any()) } + } + + @Test + fun `room empty but Firestore has it - rebuilds, persists to Room, maps option texts`() = runTest { + val local = mockk() + val firestore = mockk() + coEvery { local.getAnswer("q1") } returns null + coEvery { local.saveAnswer(any()) } just Runs + coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns meta(revealed = true) + coEvery { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) } returns + DecodedAnswer(writtenText = null, selectedOptionIds = listOf("sweet"), scaleValue = null) + + val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local) + + assertEquals("q1", result?.questionId) + assertEquals(listOf("sweet"), result?.selectedOptionIds) + assertEquals(listOf("Sweet"), result?.selectedOptionTexts) // mapped from the question config + assertTrue(result?.isRevealed == true) + coVerify(exactly = 1) { local.saveAnswer(match { it.selectedOptionIds == listOf("sweet") }) } + } + + @Test + fun `no prior answer anywhere - returns null (the normal first-answer path)`() = runTest { + val local = mockk() + val firestore = mockk() + coEvery { local.getAnswer("q1") } returns null + coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns null + + val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local) + + assertNull(result) + coVerify(exactly = 0) { local.saveAnswer(any()) } + } + + @Test + fun `assignment rotated - Firestore answer is for a different question, do not heal`() = runTest { + val local = mockk() + val firestore = mockk() + coEvery { local.getAnswer("q1") } returns null + coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns meta(questionId = "OTHER") + + val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local) + + assertNull(result) + coVerify(exactly = 0) { local.saveAnswer(any()) } + } + + @Test + fun `metadata present but content undecryptable - reports answered, does NOT poison Room`() = runTest { + val local = mockk() + val firestore = mockk() + coEvery { local.getAnswer("q1") } returns null + coEvery { firestore.getAnswerForUser(coupleId, userId, date) } returns meta() + coEvery { firestore.decryptCoupleKeyAnswerFor(coupleId, date, userId) } returns null // key unavailable + + val result = reconcileLocalAnswerFromFirestore(choiceQ, coupleId, date, userId, firestore, local) + + // Still treated as answered (so the screen won't offer a re-answer)... + assertEquals("q1", result?.questionId) + assertTrue(result?.selectedOptionIds?.isEmpty() == true) + // ...but not persisted, so a later load with the key available can recover the real content. + coVerify(exactly = 0) { local.saveAnswer(any()) } + } +}