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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user