test: pin the nip30303 store-and-submit invariant in MantraDao
Every `add*` on MantraDao does two things in one transaction: writes the entity and queues a SubmissionEvent carrying the same nip30303 event for the group. The part worth asserting is the one `rumorOf` exists for. An entity's id is computed by its `Mantra*.from*EventTemplate` factory. The payload's id is computed separately, in `rumorOf`, from the same template. The two are meant to produce the *same event* -- the row on disk and the payload on the wire, not two copies of one. Nothing enforces that: the factories live in different files, both compile independently, and both produce a plausible 64-character id. A divergence would surface only as a group that receives a submission whose payload matches nothing it can find, which is a long way from the two hash calls that disagreed. Covered, through the seam rather than by recomputing the hash: the submission records `payloadEventId`, and that value has to equal the id of the entity the same call returned. Asserted for a dialect and again for an artifact version, because store-and-submit is the convention every `add*` follows rather than something addDialect does on its own -- and the second one goes through the full foreign key chain, dialect then artifact then version. Also covered: The envelope is not the payload. A submission's own id is the SubmissionEvent's and must differ from the payload's, which is exactly why `deleteByPayloadEventId` exists -- a superseded nip30303 event cannot be un-queued by its own id, and if the two ever collapsed to one value that method would start deleting envelopes by accident. The submission is queued unprocessed, `marmotGroupEventId == null`. That null is what the outbound pipeline selects on to encrypt the row into a kind:445. Filed as processed it would be stored and never sent, and the group would simply never learn about the dialect while the local device showed it as added. A ChatMessage line is written, since the room's feed reads ChatMessage and an added entity that leaves no line is invisible to everyone including its author. Verified by mutation rather than assumed: making `rumorOf` hash a createdAt one second off the template's fails both invariant tests, with the ids compared in the failure output. The mutation was reverted; no production source is touched by this commit. 6 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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.ChatRoom
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.NostrEvent
|
||||
import press.mantra.compose.database.model.Profile
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.nip30303.DialectEvent
|
||||
import press.mantra.compose.nostr.nip30303.SubmissionEvent
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The nip30303 create-entity flow: every `add*` on [MantraDao] writes the entity and queues a
|
||||
* [SubmissionEvent] carrying the same event for the group, in one transaction.
|
||||
*
|
||||
* The invariant worth a test is the one `rumorOf` exists for. The entity's id is computed by
|
||||
* the `Mantra*.from*EventTemplate` factory and the payload's id is computed here, from the same
|
||||
* template -- so the row on disk and the payload on the wire are meant to be *the same event*,
|
||||
* not two copies of one. Nothing enforces that: both sides compile independently, both produce
|
||||
* a plausible 64-character id, and a divergence would only show up as a group that can never
|
||||
* match an arriving submission to the entity it was supposed to create.
|
||||
*/
|
||||
class MantraDaoJvmTest {
|
||||
|
||||
private val db: MantraDatabase = getRoomDatabase(
|
||||
Room.inMemoryDatabaseBuilder<MantraDatabase>()
|
||||
)
|
||||
|
||||
@AfterTest
|
||||
fun closeDb() = db.close()
|
||||
|
||||
private val author = "a".repeat(64)
|
||||
private val roomId = "b".repeat(64)
|
||||
|
||||
/** ChatRoom -> Profile -> NostrEvent, the foreign key chain a room hangs off. */
|
||||
private suspend fun seedRoom(): LocalChatRoom {
|
||||
val nostrEventId = "c".repeat(64)
|
||||
db.nostrEventDao().upsert(
|
||||
NostrEvent(
|
||||
id = nostrEventId,
|
||||
pubKey = author,
|
||||
kind = 0,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
)
|
||||
db.profileDao().upsert(Profile(publicKey = author, userName = "author", nostrEventId = nostrEventId))
|
||||
val chatRoom = ChatRoom(
|
||||
id = roomId,
|
||||
userPublicKey = author,
|
||||
subject = "a translation room",
|
||||
description = null,
|
||||
mlsGroupState = null,
|
||||
)
|
||||
db.chatRoomDao().upsert(chatRoom)
|
||||
return LocalChatRoom(chatRoom = chatRoom)
|
||||
}
|
||||
|
||||
private suspend fun submissions() = db.marmotInnerEventDao()
|
||||
.getByChatRoomAndKinds(roomId, listOf(SubmissionEvent.KIND))
|
||||
|
||||
private suspend fun addDialect(name: String = "Sesotho") = db.mantraDao().addDialect(
|
||||
localChatRoom = seedRoom(),
|
||||
name = name,
|
||||
country = "ZA",
|
||||
language = "st",
|
||||
userPublicKey = author,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a dialect is stored and queued for the group in one call`() = runBlocking {
|
||||
val dialect = assertNotNull(addDialect(), "addDialect returned null")
|
||||
|
||||
assertEquals("Sesotho", dialect.name)
|
||||
assertEquals(roomId, dialect.chatRoomId)
|
||||
assertEquals(author, dialect.publicKey)
|
||||
assertEquals(1, submissions().size, "the dialect was stored without being submitted")
|
||||
}
|
||||
|
||||
/**
|
||||
* The `rumorOf` invariant, asserted across the seam: the submission records the payload's
|
||||
* id, and that id has to be the entity's own. If the two factories ever compute it
|
||||
* differently the group receives a submission whose payload matches nothing on disk.
|
||||
*/
|
||||
@Test
|
||||
fun `the stored dialect and the submitted payload are the same event`() = runBlocking {
|
||||
val dialect = assertNotNull(addDialect())
|
||||
|
||||
val submission = submissions().single()
|
||||
|
||||
assertEquals(
|
||||
dialect.id,
|
||||
submission.payloadEventId,
|
||||
"the entity id and the submitted payload id have diverged",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope is not the payload. A submission's own id is the SubmissionEvent's, which is
|
||||
* why `deleteByPayloadEventId` exists -- a superseded nip30303 event cannot be un-queued by
|
||||
* its own id.
|
||||
*/
|
||||
@Test
|
||||
fun `the submission is an envelope with its own id`() = runBlocking {
|
||||
val dialect = assertNotNull(addDialect())
|
||||
|
||||
val submission = submissions().single()
|
||||
|
||||
assertEquals(SubmissionEvent.KIND, submission.kind)
|
||||
assertTrue(
|
||||
submission.id != dialect.id,
|
||||
"the envelope must not share the payload's id, or it could not be told apart",
|
||||
)
|
||||
assertEquals(author, submission.publicKey)
|
||||
assertEquals(roomId, submission.chatRoomId)
|
||||
}
|
||||
|
||||
/**
|
||||
* `marmotGroupEventId == null` is what makes the row unprocessed, which is the state the
|
||||
* outbound pipeline selects on to encrypt it into a kind:445. Filed as processed, it would
|
||||
* be stored and never sent, and the group would never learn about the dialect.
|
||||
*/
|
||||
@Test
|
||||
fun `the submission is queued unprocessed for the outbound pipeline`() = runBlocking {
|
||||
addDialect()
|
||||
|
||||
val submission = submissions().single()
|
||||
|
||||
assertNull(submission.marmotGroupEventId, "a queued submission must not look processed")
|
||||
}
|
||||
|
||||
/** The room's feed reads ChatMessage, so an added entity has to leave a line behind. */
|
||||
@Test
|
||||
fun `a chat message line is written so the room shows the change`() = runBlocking {
|
||||
addDialect(name = "isiZulu")
|
||||
|
||||
val submission = submissions().single()
|
||||
val chatMessage = db.chatMessageDao().getChatMessagesByMarmotInnerEventId(submission.id)
|
||||
|
||||
assertNotNull(chatMessage, "no chat line was written for the submission")
|
||||
assertEquals("Added isiZulu as a dialect", chatMessage.content)
|
||||
assertEquals(roomId, chatMessage.chatRoomId)
|
||||
assertTrue(chatMessage.isUserMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* A second entity type through the same path, because the store-and-submit shape is the
|
||||
* convention every `add*` follows rather than something `addDialect` does on its own.
|
||||
*/
|
||||
@Test
|
||||
fun `an artifact version follows the same store-and-submit shape`() = runBlocking {
|
||||
val localChatRoom = seedRoom()
|
||||
val dialect = assertNotNull(
|
||||
db.mantraDao().addDialect(
|
||||
localChatRoom = localChatRoom,
|
||||
name = "Setswana",
|
||||
country = "ZA",
|
||||
language = "tn",
|
||||
userPublicKey = author,
|
||||
)
|
||||
)
|
||||
val artifactId = "d".repeat(64)
|
||||
db.mantraArtifactDao().upsert(
|
||||
MantraArtifact(
|
||||
id = artifactId,
|
||||
publicKey = author,
|
||||
name = "a text",
|
||||
url = "https://example.invalid/text",
|
||||
visibility = MantraRepository.DEFAULT_VISIBILITY,
|
||||
dialectId = dialect.id,
|
||||
license = MantraRepository.DEFAULT_LICENSE,
|
||||
chatRoomId = roomId,
|
||||
signature = "",
|
||||
)
|
||||
)
|
||||
|
||||
val version = assertNotNull(
|
||||
db.mantraDao().addArtifactVersion(
|
||||
localChatRoom = localChatRoom,
|
||||
artifactId = artifactId,
|
||||
versionLabel = "first draft",
|
||||
userPublicKey = author,
|
||||
),
|
||||
"addArtifactVersion returned null",
|
||||
)
|
||||
|
||||
val versionSubmission = assertNotNull(
|
||||
submissions().singleOrNull { it.payloadEventId == version.id },
|
||||
"the artifact version was stored without a matching submission",
|
||||
)
|
||||
assertEquals(SubmissionEvent.KIND, versionSubmission.kind)
|
||||
assertNull(versionSubmission.marmotGroupEventId)
|
||||
assertEquals(2, submissions().size, "the dialect and the version should each be queued")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user