test(daily-question): add ReconcileLocalAnswerTest for Room/Firestore desync guard

This commit is contained in:
null 2026-06-30 19:06:14 -05:00
parent 96274d68f9
commit 02e7e6d5c3
1 changed files with 125 additions and 0 deletions

View File

@ -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<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
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<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
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<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
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<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
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<LocalAnswerRepository>()
val firestore = mockk<FirestoreAnswerDataSource>()
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()) }
}
}