test(daily-question): add ReconcileLocalAnswerTest for Room/Firestore desync guard
This commit is contained in:
parent
96274d68f9
commit
02e7e6d5c3
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue