test: cover the query a marmot reindex decides its work from

getResolvedMarmotGroupEventIds is what a reindex sweep subtracts from a room's
stored group events to decide what to replay, so its answer decides what work
the sweep does -- and both ways of being wrong are silent.

Report an event as resolved when it is not, and the replay skips the one event
that needed it: the message stays missing from the feed with nothing left to
trigger another attempt. Report it as unresolved when it is resolved, and every
sweep re-decrypts it forever. The whole distinction rests on `messageType NOT IN
(:unresolvedTypes)`, where those types are the two placeholder lines that stand
in for a message still to come rather than reporting one.

Covered: an event with a real line is resolved; an undecryptable outer layer and
a pending commit each leave their event unresolved, which is right because those
are precisely what a replay exists to retry. Then the subtraction itself, since
that is how the caller uses it -- three events, one settled, one holding a
placeholder, one with no line at all, and the sweep left with exactly the last
two.

Covered because the query says so and nothing else would: `marmotGroupEventId IS
NOT NULL` keeps out lines that are not about a group event -- a NIP-17 direct
message, a locally written line -- which would otherwise carry nulls into a set
the sweep subtracts with. And the room scoping, since a sweep runs per room and
another room's resolutions must not shorten its work.

Covered last, and it is the transition the sweep exists to cause: a placeholder
upserted in place into a real line resolves its event, visible through this same
query.

Two smaller ones alongside: the single-row lookups order newest first, which is
what makes them "the line for this event" rather than whichever row sqlite
reached first, and the per-sender count is scoped to its room.

Verified by mutation: defeating the messageType exclusion so placeholders count
as resolved fails five of these, including the subtraction test. The mutation
was reverted; no production source is touched by this commit.

8 tests. composeApp jvmTest is 282 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 03:11:58 +02:00
parent 521a4f5690
commit baecb76253

View File

@@ -0,0 +1,241 @@
package press.mantra.compose.database.dao
import androidx.room3.Room
import kotlinx.coroutines.runBlocking
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.ChatMessage
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.MarmotGroupEvent
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.Profile
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
* `getResolvedMarmotGroupEventIds` is the query a reindex sweep subtracts from the room's
* stored group events to decide what to replay, so its answer decides what work the sweep does.
* Both ways of being wrong are quiet. Report an event as resolved when it is not and the replay
* skips the one event that needed it -- the message stays missing from the feed with nothing
* left to trigger another attempt. Report it as unresolved when it is resolved and every sweep
* re-decrypts it forever.
*
* The distinction rests entirely on `messageType NOT IN (:unresolvedTypes)`, where the
* unresolved types are the two placeholder lines that stand in for a message still to come
* rather than reporting one.
*/
class ChatMessageDaoJvmTest {
private val db: MantraDatabase = getRoomDatabase(
Room.inMemoryDatabaseBuilder<MantraDatabase>()
)
@AfterTest
fun closeDb() = db.close()
private val user = "a".repeat(64)
private val other = "b".repeat(64)
private val roomOne = "11".repeat(32)
private val roomTwo = "22".repeat(32)
private suspend fun seedRooms() {
val nostrEventId = "c".repeat(64)
db.nostrEventDao().upsert(
NostrEvent(
id = nostrEventId,
pubKey = user,
kind = 0,
tags = emptyArray(),
content = "{}",
sig = "0".repeat(128),
)
)
db.profileDao().upsert(Profile(publicKey = user, userName = "user", nostrEventId = nostrEventId))
listOf(roomOne, roomTwo).forEach { id ->
db.chatRoomDao().upsert(
ChatRoom(
id = id,
userPublicKey = user,
subject = null,
description = null,
mlsGroupState = null,
)
)
}
}
/** A stored kind:445 plus its indexed row, which a chat line's foreign key hangs off. */
private suspend fun seedGroupEvent(id: String, chatRoomId: String = roomOne): String {
val eventId = id.padEnd(64, '0')
db.nostrEventDao().upsert(
NostrEvent(
id = eventId,
pubKey = user,
kind = MarmotGroupEvent.KIND,
tags = arrayOf(arrayOf("h", chatRoomId)),
content = "ciphertext",
sig = "0".repeat(128),
)
)
db.marmotGroupEventDao().upsert(
MarmotGroupEvent(
id = eventId,
userPublicKey = user,
publicKey = user,
chatRoomId = chatRoomId,
signature = "0".repeat(128),
encryptedContent = "ciphertext",
expiresAt = null,
)
)
return eventId
}
private suspend fun line(
groupEventId: String?,
messageType: String = ChatMessage.TYPE_DIRECT_MESSAGE,
chatRoomId: String = roomOne,
content: String = "a line",
sender: String = user,
createdAt: Instant = Instant.fromEpochSeconds(1_000),
): Long = db.chatMessageDao().upsert(
ChatMessage(
content = content,
chatRoomId = chatRoomId,
senderPublicKey = sender,
isUserMessage = sender == user,
giftWrapPayloadId = null,
marmotGroupEventId = groupEventId,
marmotInnerEventId = null,
messageType = messageType,
createdAt = createdAt,
)
)
private suspend fun resolved(chatRoomId: String = roomOne) =
db.chatMessageDao().getResolvedMarmotGroupEventIds(
chatRoomId = chatRoomId,
unresolvedTypes = ChatMessage.UNRESOLVED_MARMOT_TYPES,
)
@Test
fun `an event with a real line is resolved`() = runBlocking {
seedRooms()
val eventId = seedGroupEvent("1")
line(eventId)
assertEquals(listOf(eventId), resolved())
}
/**
* The two placeholder types. An undecryptable outer layer stands in for a message that
* could not be opened yet, and a pending commit for one whose commit has not arrived --
* both are exactly what a replay exists to retry, so neither may count as resolved.
*/
@Test
fun `a placeholder line leaves its event unresolved`() = runBlocking {
seedRooms()
val undecryptable = seedGroupEvent("1")
val pendingCommit = seedGroupEvent("2")
line(undecryptable, messageType = ChatMessage.TYPE_UNDECRYPTABLE_OUTER_LAYER)
line(pendingCommit, messageType = ChatMessage.TYPE_PENDING_COMMIT)
val found = resolved()
assertTrue(undecryptable !in found, "an undecryptable placeholder is not a resolution")
assertTrue(pendingCommit !in found, "a pending commit is not a resolution")
assertTrue(found.isEmpty())
}
/** The sweep's subtraction: only the placeholder-backed event is left to replay. */
@Test
fun `a replay is left with exactly the events that still have nothing to show`() = runBlocking {
seedRooms()
val settled = seedGroupEvent("1")
val stillWaiting = seedGroupEvent("2")
val neverSeen = seedGroupEvent("3")
line(settled)
line(stillWaiting, messageType = ChatMessage.TYPE_PENDING_COMMIT)
val unresolved = listOf(settled, stillWaiting, neverSeen) - resolved().toSet()
assertEquals(listOf(stillWaiting, neverSeen), unresolved)
}
/**
* Lines that are not about a group event -- a NIP-17 direct message, a locally written
* line -- carry a null marmotGroupEventId, and `IS NOT NULL` keeps them out. Without it
* the result would carry nulls into a set the sweep subtracts with.
*/
@Test
fun `lines with no group event are not reported as resolutions`() = runBlocking {
seedRooms()
line(groupEventId = null)
assertTrue(resolved().isEmpty())
}
/** A sweep runs per room, so another room's resolved lines must not shorten its work. */
@Test
fun `resolutions are scoped to their own room`() = runBlocking {
seedRooms()
val mine = seedGroupEvent("1", chatRoomId = roomOne)
val theirs = seedGroupEvent("2", chatRoomId = roomTwo)
line(mine, chatRoomId = roomOne)
line(theirs, chatRoomId = roomTwo)
assertEquals(listOf(mine), resolved(roomOne))
assertEquals(listOf(theirs), resolved(roomTwo))
}
/**
* A placeholder replaced by a real line resolves the event. This is the transition the
* sweep is trying to cause, so it has to be visible through this query -- the row is
* upserted in place, keeping its id.
*/
@Test
fun `a placeholder that becomes a real line resolves its event`() = runBlocking {
seedRooms()
val eventId = seedGroupEvent("1")
val lineId = line(eventId, messageType = ChatMessage.TYPE_PENDING_COMMIT)
assertTrue(resolved().isEmpty(), "precondition: the placeholder is unresolved")
val placeholder = assertNotNull(db.chatMessageDao().getChatMessagesByMarmotGroupEventId(eventId))
db.chatMessageDao().upsert(
placeholder.copy(id = lineId, messageType = ChatMessage.TYPE_DIRECT_MESSAGE, content = "decrypted")
)
assertEquals(listOf(eventId), resolved())
}
/**
* The single-row lookups order newest first, which is what makes them a sensible "the line
* for this event" rather than whichever row sqlite reached first.
*/
@Test
fun `the newest line wins for a group event`() = runBlocking {
seedRooms()
val eventId = seedGroupEvent("1")
line(eventId, content = "older", createdAt = Instant.fromEpochSeconds(1_000))
line(eventId, content = "newer", createdAt = Instant.fromEpochSeconds(2_000))
assertEquals("newer", db.chatMessageDao().getChatMessagesByMarmotGroupEventId(eventId)?.content)
}
@Test
fun `a senders lines are counted per room`() = runBlocking {
seedRooms()
line(groupEventId = null, sender = user, chatRoomId = roomOne)
line(groupEventId = null, sender = user, chatRoomId = roomOne)
line(groupEventId = null, sender = other, chatRoomId = roomOne)
line(groupEventId = null, sender = user, chatRoomId = roomTwo)
assertEquals(2, db.chatMessageDao().countChatMessagesBySenderPublicKey(roomOne, user))
assertEquals(1, db.chatMessageDao().countChatMessagesBySenderPublicKey(roomOne, other))
assertEquals(0, db.chatMessageDao().countChatMessagesBySenderPublicKey(roomTwo, other))
}
}