From 93f8ab313cf910312916c7e25abe081fdf03f467 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:26:05 +0200 Subject: [PATCH] test(projects): cover the three view models and the query nothing asked about The feature's logic was tested from the events down to the chronicle and not at all above the repository. Four gaps, each one something no other layer would catch. **AddProjectViewModel.** A project and its contents go out as one batch whose memberships carry the project's id -- which is a hash over the group's key at the room's path and does not exist until the proposal is made, so the screen hands over a function of the signed lead. Nothing downstream notices if that function returns the wrong thing: a membership naming another project is a valid event, signs cleanly, and lands as a row pointing elsewhere. Eight cases assert the batch's shape against a recording repository that signs the lead the way a finished session does, including that the creator is the member filling the form in -- there is no field for it and nothing else to check it against. **AddToProjectViewModel.** The candidate filtering is the only thing stopping a duplicate membership, because a duplicate is a valid event with its own id: the keys are satisfied, the signature is good, the row gets written. A wrong filter would spend a quorum saying what the group has already said and look like the feature working. Ten cases, including that the two halves filter independently. **FrostSigningViewModel's resolver.** Unresolved, the signing screen asks a member to agree to "a4f1be55d0c3". Seven cases through the same `summarize` call the screen makes: a membership reads as the artifact and label, a stranger falls back to a hash rather than an invented name, the room owning its own work says nothing, and a project being signed in the same batch resolves to nothing -- which is why a membership says the version first. **getProjectsByTranslationArtifactVersionId** had no test at any layer while its artifact-side twin had one from the start. That asymmetry is what a copied join hides. Two notes for whoever writes the next view-model test. These view models hold state in `mutableStateOf` and initiate on `Dispatchers.IO`, so there is nothing to collect and the call returns before the work is done -- hence `awaitValue`, polling in real time for the same reason `NavigationIdentityRoutingJvmTest` does. And `NostrRepository.NO_OP_NOSTR_REPOSITORY.getProfileWithPublicKey` is a `TODO()`, so a resolver that asks it about anybody throws on a background dispatcher and surfaces as a timeout rather than a failure anybody can read; these read profiles from the seeded database instead, which also gives the honest answer for a stranger. 983 jvmTest, 447 testDebugUnitTest, no failures. Co-Authored-By: Claude Opus 5 --- .../database/dao/MantraProjectDaoJvmTest.kt | 36 ++ .../view/model/AddProjectViewModelJvmTest.kt | 299 ++++++++++++++++ .../model/AddToProjectViewModelJvmTest.kt | 276 ++++++++++++++ .../model/FrostSigningProjectNamesJvmTest.kt | 338 ++++++++++++++++++ .../ui/view/model/ProjectViewModelFixtures.kt | 235 ++++++++++++ 5 files changed, 1184 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt index 648d3d0b..9b03353a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/MantraProjectDaoJvmTest.kt @@ -291,6 +291,42 @@ class MantraProjectDaoJvmTest { ) } + @Test + fun `a translation says which projects it is in`() = runBlocking { + seed() + val projectId = "p".repeat(64) + project(id = projectId, name = "Gospel of John") + + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = roomId, + projectId = projectId, + translationArtifactVersionId = translationId, + chatRoomId = roomId, + signature = signature, + ) + ) + + // The artifact-side twin of this had a test from the start and the + // translation side did not, which is the kind of asymmetry a copied + // query hides: both joins are three lines and one of them could have + // named the wrong column without anything noticing. + assertEquals( + listOf("Gospel of John"), + db.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId(translationId) + .map { it.name } + ) + + // And it is specific to the translation rather than to the project. + assertTrue( + db.mantraProjectTranslationArtifactVersionDao() + .getProjectsByTranslationArtifactVersionId("9".repeat(64)) + .isEmpty() + ) + } + @Test fun `a membership naming a version this room does not hold is refused`() = runBlocking { seed() diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt new file mode 100644 index 00000000..115558e0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddProjectViewModelJvmTest.kt @@ -0,0 +1,299 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.room3.Room +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AddProjectUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * What the form actually puts in front of the group. + * + * A project and its first contents go out as one batch whose memberships carry + * the project's id -- and that id is a hash over the group's key at the room's + * derivation path, so it does not exist until the proposal is made. The screen + * therefore hands over a *function* of the signed lead rather than a list, and + * nothing downstream would notice if that function returned the wrong thing: a + * membership naming the wrong project is a valid event, signs cleanly, and + * lands as a row pointing somewhere else. + * + * So these assert the batch's shape against a recording repository that signs + * the lead the way a finished session does. Three properties, each of which has + * a silent failure behind it: + * + * - the memberships name the project that was signed, not one worked out + * beforehand; + * - both halves are in the batch, since the two lists come from two different + * `membershipsOf` calls and dropping one is a dropped selection; + * - the creator on the event is the member filling the form in, because there + * is no field for it and nothing else to check it against. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddProjectViewModelJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private fun viewModel(signing: RecordingFrostSigningRepository) = AddProjectViewModel( + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + relayHint = null, + initialAddProjectUIState = AddProjectUIState.Loading, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = DatabaseChatRepository(db, scope), + mantraRepository = DatabaseMantraRepository(db, scope), + frostSigningRepository = signing, + ) + + /** Opens the form and waits for the room and library to land. */ + private suspend fun loaded(signing: RecordingFrostSigningRepository): Pair { + val viewModel = viewModel(signing) + viewModel.initiateAddProject() + return viewModel to awaitValue { + viewModel.addProjectUIState as? AddProjectUIState.Loaded + } + } + + /** + * Proposes, and waits for whichever callback the view model reaches. + * + * Both fire on `Dispatchers.Main` from inside a `Dispatchers.IO` launch, so + * `addProject` returns long before either runs. + */ + private suspend fun propose( + viewModel: AddProjectViewModel, + state: AddProjectUIState.Loaded, + name: String = "Gospel of John into Sesotho", + description: String = "The four chapters.", + owners: Set = setOf(room.roomId), + sourceVersionIds: List = emptyList(), + translationIds: List = emptyList(), + ): String? { + val outcome = CompletableDeferred() + + viewModel.addProject( + localChatRoom = state.localChatRoom, + nameField = TextFieldState(name), + descriptionField = TextFieldState(description), + ownerPublicKeys = owners, + sourceVersionIds = sourceVersionIds, + translationIds = translationIds, + onSuccess = { outcome.complete(it) }, + onFailure = { outcome.complete(null) } + ) + + return withTimeout(10_000) { outcome.await() } + } + + // --- what the form offers --- + + @Test + fun `the form offers every version in the library and every translation`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (_, state) = loaded(signing) + + // Versions have no by-room query -- they hang off an artifact -- so this + // is the walk down the tree, and a walk that stopped at the first + // artifact would look like a library with one version in it. + assertEquals( + setOf(room.versionId, room.secondVersionId), + state.sourceVersions.map { it.id }.toSet() + ) + assertEquals(listOf(room.translationId), state.translations.map { it.id }) + assertTrue(state.canSign) + } + + // --- what goes to the group --- + + @Test + fun `the batch is the project, then its sources, then its translations`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + val sessionId = propose( + viewModel, state, + sourceVersionIds = listOf(room.versionId, room.secondVersionId), + translationIds = listOf(room.translationId), + ) + + assertEquals("session-1", sessionId) + + assertEquals( + listOf( + ProjectEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectArtifactVersionEvent.KIND, + ProjectTranslationArtifactVersionEvent.KIND, + ), + signing.proposed.map { it.kind }, + "the project leads, because the memberships are derived from its id" + ) + } + + @Test + fun `every membership names the project the session signed`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose( + viewModel, state, + description = "", + sourceVersionIds = listOf(room.versionId), + translationIds = listOf(room.translationId), + ) + ) + + val project = signing.proposed.first() + + // The point of the whole lead-and-dependents shape. Nothing else checks + // this: a membership naming some other id is a valid signed event that + // lands as a row pointing at nothing. + val source = signing.proposed.single { it.kind == ProjectArtifactVersionEvent.KIND } + .let { + ProjectArtifactVersionEvent( + it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig + ) + } + assertEquals(project.id, source.projectId()) + assertEquals(room.versionId, source.artifactVersionId()) + + val translation = signing.proposed + .single { it.kind == ProjectTranslationArtifactVersionEvent.KIND } + .let { + ProjectTranslationArtifactVersionEvent( + it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig + ) + } + assertEquals(project.id, translation.projectId()) + assertEquals(room.translationId, translation.translationArtifactVersionId()) + + // And they read as one act rather than four events sharing a session. + assertTrue(signing.proposed.all { it.createdAt == project.createdAt }) + } + + @Test + fun `the creator is the member filling the form in, and the owners are what was chosen`() = + runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose(viewModel, state, owners = setOf(room.roomId, room.otherMember)), + "a project with no contents is still a project" + ) + + val project = signing.proposed.single().let { + ProjectEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig) + } + + // There is no creator field, so this is the only thing that says the + // right person is credited for what the group is about to sign. + assertEquals(room.member, project.creator()) + assertEquals(setOf(room.roomId, room.otherMember), project.owners().toSet()) + assertEquals("Gospel of John into Sesotho", project.name()) + assertEquals("The four chapters.", project.content) + + // The author is the room, not the creator: the group signs it. + assertEquals(room.roomId, signing.proposed.single().pubKey) + } + + @Test + fun `a project with no contents proposes one event`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertEquals( + "session-1", + propose(viewModel, state, description = ""), + "an empty project is a name waiting for work" + ) + + assertEquals(listOf(ProjectEvent.KIND), signing.proposed.map { it.kind }) + } + + // --- what the form refuses to send --- + + @Test + fun `a blank name proposes nothing`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + assertNull( + propose(viewModel, state, name = " "), + "a nameless project should not reach a quorum" + ) + assertTrue(signing.proposed.isEmpty()) + } + + @Test + fun `an empty owner set proposes nothing rather than throwing`() = runBlocking { + room.seed() + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing) + + // `ProjectEvent.build` throws on an empty set, so the view model has to + // refuse first. The form disables the last remove, but the guard is what + // makes that a safety rather than the only thing standing in the way. + assertNull( + propose(viewModel, state, description = "", owners = emptySet()), + "a project with no owner cannot be built" + ) + assertTrue(signing.proposed.isEmpty()) + } + + @Test + fun `a room that cannot be found is an error rather than an empty form`() = runBlocking { + // Nothing seeded. + val viewModel = viewModel(RecordingFrostSigningRepository(room.roomId)) + + viewModel.initiateAddProject() + + val error = awaitValue { viewModel.addProjectUIState as? AddProjectUIState.Error } + + assertTrue(error.message.isNotBlank(), "the error should say something") + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt new file mode 100644 index 00000000..e9e0d948 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AddToProjectViewModelJvmTest.kt @@ -0,0 +1,276 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.MantraProjectArtifactVersion +import press.mantra.compose.database.model.MantraProjectTranslationArtifactVersion +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.ui.view.state.AddToProjectUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Which work the picker offers, and what one pick becomes. + * + * The filtering is the part that matters and the part nothing else would catch. + * A membership that duplicates one the group has already signed is a *valid* + * event with its own id -- the foreign keys are satisfied, the signature is + * good, the apply arm writes the row -- so there is no layer below this that + * says no. If the filter were wrong the only symptom would be a quorum spent + * saying something the group has already said, which looks like the feature + * working. + * + * The other half is that one pick is one event. The add-to flow knows the + * project's id because the project exists, so it uses the flat batch rather + * than lead-and-dependents, and a membership built against the wrong id would + * land as a row pointing somewhere else. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AddToProjectViewModelJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + private val projectId = "p".repeat(64) + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + private fun viewModel( + signing: RecordingFrostSigningRepository, + translations: Boolean, + ) = AddToProjectViewModel( + projectId = projectId, + chatRoomId = room.roomId, + activeUserPublicKey = room.member, + translations = translations, + relayHint = null, + initialAddToProjectUIState = AddToProjectUIState.Loading, + chatRepository = DatabaseChatRepository(db, scope), + mantraRepository = DatabaseMantraRepository(db, scope), + frostSigningRepository = signing, + ) + + private suspend fun loaded( + signing: RecordingFrostSigningRepository, + translations: Boolean, + ): Pair { + val viewModel = viewModel(signing, translations) + viewModel.initiateAddToProject() + return viewModel to awaitValue { + viewModel.addToProjectUIState as? AddToProjectUIState.Loaded + } + } + + private suspend fun propose( + viewModel: AddToProjectViewModel, + state: AddToProjectUIState.Loaded, + selectedId: String, + ): String? { + val outcome = CompletableDeferred() + + viewModel.addToProject( + localChatRoom = state.localChatRoom, + selectedId = selectedId, + onSuccess = { outcome.complete(it) }, + onFailure = { outcome.complete(null) } + ) + + return withTimeout(10_000) { outcome.await() } + } + + /** A membership already signed, so the picker has something to leave out. */ + private suspend fun alreadyHolds(versionId: String) = + db.mantraProjectArtifactVersionDao().upsert( + MantraProjectArtifactVersion( + id = "m1".padEnd(64, '0'), + publicKey = room.roomId, + projectId = projectId, + artifactVersionId = versionId, + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + + // --- which work is offered --- + + @Test + fun `the source picker offers every version in the library`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + assertEquals( + setOf(room.versionId, room.secondVersionId), + state.candidates.map { it.id }.toSet() + ) + assertTrue(state.canSign) + } + + @Test + fun `a version already in the project is not offered again`() = runBlocking { + room.seed() + room.project(id = projectId) + alreadyHolds(room.versionId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + // The whole point. Nothing below this refuses a duplicate: it is a valid + // event with its own id, so it would cost a quorum to say what the group + // has already said. + assertEquals(listOf(room.secondVersionId), state.candidates.map { it.id }) + } + + @Test + fun `a version is named by its artifact as well as its label`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = false) + + val candidate = state.candidates.single { it.id == room.versionId } + + // "1.0" on its own is not a choice -- a room has several of those. + assertEquals(room.versionLabel, candidate.label) + assertEquals(room.artifactName, candidate.detail) + } + + @Test + fun `the translation picker offers translations, not versions`() = runBlocking { + room.seed() + room.project(id = projectId) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertEquals(listOf(room.translationId), state.candidates.map { it.id }) + assertEquals(room.translationName, state.candidates.single().label) + } + + @Test + fun `a translation already in the project is not offered again`() = runBlocking { + room.seed() + room.project(id = projectId) + db.mantraProjectTranslationArtifactVersionDao().upsert( + MantraProjectTranslationArtifactVersion( + id = "m2".padEnd(64, '0'), + publicKey = room.roomId, + projectId = projectId, + translationArtifactVersionId = room.translationId, + chatRoomId = room.roomId, + signature = room.signature, + ) + ) + + val (_, state) = loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertTrue(state.candidates.isEmpty()) + } + + @Test + fun `the two halves filter independently`() = runBlocking { + room.seed() + room.project(id = projectId) + // A source membership must not hide a translation, and the translation + // this one names is of that very version -- which is what a filter + // written against the wrong table would get wrong. + alreadyHolds(room.versionId) + + val (_, translationState) = + loaded(RecordingFrostSigningRepository(room.roomId), translations = true) + + assertEquals(listOf(room.translationId), translationState.candidates.map { it.id }) + } + + // --- what one pick becomes --- + + @Test + fun `picking a source proposes one membership naming the project`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing, translations = false) + + assertEquals("session-1", propose(viewModel, state, room.versionId)) + + val proposed = signing.proposed.single() + assertEquals(ProjectArtifactVersionEvent.KIND, proposed.kind) + + val membership = ProjectArtifactVersionEvent( + proposed.id, proposed.pubKey, proposed.createdAt, + proposed.tags, proposed.content, proposed.sig + ) + assertEquals(projectId, membership.projectId()) + assertEquals(room.versionId, membership.artifactVersionId()) + } + + @Test + fun `picking a translation proposes the translation kind`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId) + val (viewModel, state) = loaded(signing, translations = true) + + assertEquals("session-1", propose(viewModel, state, room.translationId)) + + val proposed = signing.proposed.single() + assertEquals(ProjectTranslationArtifactVersionEvent.KIND, proposed.kind) + + val membership = ProjectTranslationArtifactVersionEvent( + proposed.id, proposed.pubKey, proposed.createdAt, + proposed.tags, proposed.content, proposed.sig + ) + assertEquals(projectId, membership.projectId()) + assertEquals(room.translationId, membership.translationArtifactVersionId()) + } + + @Test + fun `a session that will not open is reported rather than swallowed`() = runBlocking { + room.seed() + room.project(id = projectId) + val signing = RecordingFrostSigningRepository(room.roomId).also { it.session = null } + val (viewModel, state) = loaded(signing, translations = false) + + assertNull(propose(viewModel, state, room.versionId)) + } + + // --- what it refuses to open at all --- + + @Test + fun `a project this device does not hold is an error`() = runBlocking { + room.seed() + // The room is there, the project is not. + val viewModel = viewModel(RecordingFrostSigningRepository(room.roomId), translations = false) + viewModel.initiateAddToProject() + + val error = awaitValue { viewModel.addToProjectUIState as? AddToProjectUIState.Error } + + assertTrue(error.message.isNotBlank()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt new file mode 100644 index 00000000..8bfb2abb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/FrostSigningProjectNamesJvmTest.kt @@ -0,0 +1,338 @@ +package press.mantra.compose.ui.view.model + +import androidx.room3.Room +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.builder.getRoomDatabase +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.FrostSignerMessage +import press.mantra.compose.database.repository.DatabaseChatRepository +import press.mantra.compose.database.repository.DatabaseMantraRepository +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.view.state.FrostSigningUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * What a member is actually shown before they sign something about a project. + * + * A membership event carries two 64-character ids and nothing else. Unresolved, + * the signing screen asks somebody to agree to "a4f1be55d0c3" -- which is not a + * question anybody can answer, and it is the whole reason + * `FrostSigningViewModel` resolves names before handing `ProposedEvent.summarize` + * a lookup. + * + * Nothing downstream notices if that resolution is wrong or missing. The summary + * still renders, the session still signs, and the member still taps approve. + * These tests are the only thing standing between the resolver and a screen that + * quietly goes back to showing hashes: + * + * - a membership resolves to the artifact and label a reader recognises; + * - a project's creator and outside owners resolve to people; + * - the room's own key is *not* named as an owner, because saying "this group" + * on every project teaches a member to skip the line that matters; + * - an id this device has never seen falls back to a short hash rather than + * inventing a name or dropping the line. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FrostSigningProjectNamesJvmTest { + + private val db: MantraDatabase = getRoomDatabase( + Room.inMemoryDatabaseBuilder() + ) + private val room = ProjectRoom(db) + private val projectId = "p".repeat(64) + private val sessionId = "session-1" + + @BeforeTest + fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + db.close() + } + + private val scope = CoroutineScope(UnconfinedTestDispatcher()) + + /** A session whose items are [events], which is all this screen reads. */ + private class StubSigning( + private val chatRoomId: String, + private val events: List, + private val delegate: FrostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) : FrostSigningRepository by delegate { + + override fun observeSessionById(sessionId: String): Flow = flowOf( + FrostSigningSession( + id = sessionId, + chatRoomId = chatRoomId, + coordinatorPublicKey = chatRoomId, + userPublicKey = chatRoomId, + dkgSessionId = "ceremony-1", + threshold = 2, + participantCount = 3, + signerId = 0, + ) + ) + + override fun observeMessages(sessionId: String): Flow> = + flowOf(emptyList()) + + override fun observeItems(sessionId: String): Flow> = flowOf( + events.mapIndexed { index, event -> + FrostSigningItem( + sessionId = sessionId, + itemIndex = index, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "a".repeat(64), + ) + } + ) + } + + private fun signed(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = room.roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = room.roomId, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "f".repeat(128), + ) + + /** + * The names the signing screen would be handed for [events]. + * + * The profile source is a real read of the seeded database rather than the + * no-op, whose `getProfileWithPublicKey` is a `TODO()` -- so a resolver that + * asks it about anybody throws on `Dispatchers.IO`, which surfaces as this + * helper timing out rather than as a failure anybody can read. Reading the + * database gives the honest answer for a stranger too: null. + */ + private suspend fun namesFor(events: List): Map { + val viewModel = FrostSigningViewModel( + chatRoomId = room.roomId, + sessionId = sessionId, + activeUserPublicKey = room.member, + initialFrostSigningUIState = FrostSigningUIState.Loading, + chatRepository = DatabaseChatRepository(db, scope), + frostSigningRepository = StubSigning(room.roomId, events), + mantraRepository = DatabaseMantraRepository(db, scope), + nostrRepository = ProfileReadingNostrRepository(db), + ) + + viewModel.initiate() + + return awaitValue { + (viewModel.frostSigningUIState as? FrostSigningUIState.Loaded) + ?.takeIf { it.items.isNotEmpty() } + ?.names + } + } + + private fun membership(versionId: String) = signed( + ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = versionId, + createdAt = 1_700_000_000, + ) + ) + + // --- a membership proposed on its own --- + + @Test + fun `a membership resolves to the artifact and the label`() = runBlocking { + room.seed() + room.project(id = projectId) + + val names = namesFor(listOf(membership(room.versionId))) + + assertEquals("${room.artifactName} ${room.versionLabel}", names[room.versionId]) + assertEquals("Gospel of John", names[projectId]) + } + + @Test + fun `and so the summary a member reads is words rather than a hash`() = runBlocking { + room.seed() + room.project(id = projectId) + + val event = membership(room.versionId) + val names = namesFor(listOf(event)) + + // The end the resolver exists for, asserted through the same call the + // signing screen makes. + val summary = ProposedEvent.summarize( + event = event, + chatRoomId = room.roomId, + resolve = names::get, + ) + + assertEquals("A source version joins the project", summary.label) + assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", summary.detail) + assertTrue( + !summary.detail.contains(room.versionId.take(12)), + "a resolved membership should not still be showing a hash" + ) + } + + @Test + fun `a translation membership resolves to the translation's name`() = runBlocking { + room.seed() + room.project(id = projectId) + + val event = signed( + ProjectTranslationArtifactVersionEvent.build( + projectId = projectId, + translationArtifactVersionId = room.translationId, + createdAt = 1_700_000_000, + ) + ) + + assertEquals(room.translationName, namesFor(listOf(event))[room.translationId]) + } + + // --- what cannot be resolved --- + + @Test + fun `a version this device has never seen is left to the hash`() = runBlocking { + room.seed() + room.project(id = projectId) + val absent = "9".repeat(64) + + val event = membership(absent) + val names = namesFor(listOf(event)) + + // Left out rather than guessed at. The device has not seen the thing, + // and a short hash says so; inventing a name would be worse than saying + // nothing. + assertTrue(absent !in names) + + val summary = ProposedEvent.summarize(event, room.roomId, names::get) + assertEquals(absent.take(12) + " · to Gospel of John", summary.detail) + } + + @Test + fun `a project being signed in the same batch resolves to nothing`() = runBlocking { + room.seed() + // No project row: this is the founding batch, where the project is on the + // line above and nothing has applied it yet. + val project = signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId), + createdAt = 1_700_000_000, + ) + ) + val source = signed( + ProjectArtifactVersionEvent.build( + projectId = project.id, + artifactVersionId = room.versionId, + createdAt = 1_700_000_000, + ) + ) + + val names = namesFor(listOf(project, source)) + + assertTrue( + project.id !in names, + "nothing has applied the project yet, which is why a membership says the version first" + ) + // And the version still resolves, which is what makes the batch readable. + assertEquals("${room.artifactName} ${room.versionLabel}", names[room.versionId]) + } + + // --- the people on a project --- + + @Test + fun `a project's creator resolves to a profile`() = runBlocking { + room.seed() + + // NostrRepository is the no-op here, so prove the wiring with a stub + // that answers for the member and nobody else. + val names = namesFor( + listOf( + signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId, room.otherMember), + createdAt = 1_700_000_000, + ) + ) + ) + ) + + assertEquals("kgothatso", names[room.member]) + } + + @Test + fun `an owner with no profile is left to the hash, and the room is left out of the line`() = + runBlocking { + room.seed() + + val project = signed( + ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = room.member, + ownerPublicKeys = setOf(room.roomId, room.otherMember), + createdAt = 1_700_000_000, + ) + ) + val names = namesFor(listOf(project)) + + assertTrue(room.otherMember !in names, "a stranger has no name to show") + + val summary = ProposedEvent.summarize(project, room.roomId, names::get) + + assertTrue(summary.detail.contains("started by kgothatso"), summary.detail) + assertTrue( + summary.detail.contains("owned by ${room.otherMember.take(12)}"), + summary.detail + ) + assertTrue( + !summary.detail.contains(room.roomId.take(12)), + "the room owning its own work is the ordinary case and says nothing" + ) + } + + /** Only the one method the resolver reaches for. */ + private class ProfileReadingNostrRepository( + private val db: MantraDatabase, + private val delegate: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + ) : NostrRepository by delegate { + override suspend fun getProfileWithPublicKey(publicKey: String) = + db.profileDao().getProfileByPublicKey(publicKey) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt new file mode 100644 index 00000000..c7316fb5 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/ProjectViewModelFixtures.kt @@ -0,0 +1,235 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.MantraArtifact +import press.mantra.compose.database.model.MantraArtifactVersion +import press.mantra.compose.database.model.MantraDialect +import press.mantra.compose.database.model.MantraProject +import press.mantra.compose.database.model.MantraTranslationArtifactVersion +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.repository.FrostSigningRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout + +/** + * Waits for [read] to stop returning null, or gives up. + * + * These view models hold their state in `mutableStateOf` rather than a flow -- + * the convention the rest of this family follows -- so there is nothing to + * collect, and `initiate` launches on `Dispatchers.IO`, so the call returns + * before the work is done. Polling in real time for the same reason + * `NavigationIdentityRoutingJvmTest` does: `runTest` would advance a virtual + * clock past a real database read rather than waiting for it. + */ +suspend fun awaitValue(timeoutMillis: Long = 10_000, read: () -> T?): T = + withTimeout(timeoutMillis) { + var value = read() + while (value == null) { + delay(5) + value = read() + } + value + } + +/** + * The room, the library and the translation every project view model needs + * before it has anything to do. + * + * Shared rather than copied three times: the chain is five foreign keys deep and + * none of it is what any of these tests are about. + */ +class ProjectRoom(private val db: MantraDatabase) { + val roomId = "b".repeat(64) + val signature = "0".repeat(128) + + val member = "a".repeat(64) + val otherMember = "7".repeat(64) + + val dialectId = "1".repeat(64) + val artifactId = "2".repeat(64) + val versionId = "3".repeat(64) + val secondVersionId = "6".repeat(64) + val translationId = "5".repeat(64) + + val artifactName = "To Kill a Mockingbird" + val versionLabel = "1.0" + val translationName = "Sesotho" + + suspend fun seed() { + val nostrEventId = "e".repeat(64) + db.nostrEventDao().upsert( + NostrEvent( + id = nostrEventId, + pubKey = member, + kind = 0, + tags = emptyArray(), + content = "{}", + sig = signature, + ) + ) + db.profileDao().upsert( + Profile(publicKey = member, userName = "kgothatso", nostrEventId = nostrEventId) + ) + db.chatRoomDao().upsert( + ChatRoom( + id = roomId, + userPublicKey = member, + subject = "a translation room", + description = null, + mlsGroupState = null, + ) + ) + + db.mantraDialectDao().upsert( + MantraDialect( + id = dialectId, + publicKey = roomId, + name = "Sesotho", + country = "ZA", + language = "st", + signature = signature, + chatRoomId = roomId, + ) + ) + db.mantraArtifactDao().upsert( + MantraArtifact( + id = artifactId, + publicKey = roomId, + name = artifactName, + url = "https://harper.com/2-kill-Bird", + visibility = "private", + dialectId = dialectId, + license = "cc", + chatRoomId = roomId, + signature = signature, + ) + ) + listOf(versionId to versionLabel, secondVersionId to "2.0").forEach { (id, label) -> + db.mantraArtifactVersionDao().upsert( + MantraArtifactVersion( + id = id, + artifactId = artifactId, + publicKey = roomId, + versionLabel = label, + signature = signature, + chatRoomId = roomId, + ) + ) + } + db.mantraTranslationArtifactVersionDao().upsert( + MantraTranslationArtifactVersion( + id = translationId, + publicKey = roomId, + artifactVersionId = versionId, + dialectId = dialectId, + name = translationName, + visibility = "private", + license = "cc", + signature = signature, + chatRoomId = roomId, + ) + ) + } + + /** A project already signed into the room, for the add-to flow to add to. */ + suspend fun project( + id: String = "p".repeat(64), + name: String = "Gospel of John", + owners: Set = setOf(roomId), + ): MantraProject = MantraProject( + id = id, + publicKey = roomId, + name = name, + description = "The four chapters.", + creatorPublicKey = member, + ownerPublicKeys = owners.sorted().joinToString(","), + chatRoomId = roomId, + signature = signature, + ).also { db.mantraProjectDao().upsert(it) } + + suspend fun localChatRoom(): LocalChatRoom = + db.chatRoomDao().findChatRoomById(roomId) ?: error("the room was not seeded") +} + +/** + * A signing repository that records what it was asked to sign instead of asking + * a group. + * + * Delegates everything else to the no-op, so it keeps compiling when the + * interface grows and says nothing about methods these tests are not aiming at. + * + * The lead-and-dependents overload is emulated rather than stubbed, because that + * is the part worth checking: the real one signs the lead first -- resolving the + * room's key and so the lead's id -- and only then asks the caller what refers + * to it. A fake that handed back an unsigned lead, or never called `dependents`, + * would let a batch that cannot work pass. + */ +class RecordingFrostSigningRepository( + private val authorPublicKey: HexKey, + private val delegate: FrostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, +) : FrostSigningRepository by delegate { + + /** Every event the session was opened over, lead first. */ + var proposed: List = emptyList() + private set + + /** Null to make the proposal fail, which is the screens' other path. */ + var session: FrostSigningSession? = FrostSigningSession( + id = "session-1", + chatRoomId = "b".repeat(64), + coordinatorPublicKey = authorPublicKey, + userPublicKey = authorPublicKey, + dkgSessionId = "ceremony-1", + threshold = 2, + participantCount = 3, + signerId = 0, + ) + + override suspend fun canSign(chatRoomId: String): Boolean = true + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + events: List> + ): FrostSigningSession? { + proposed = events.map { signed(it) } + return session + } + + override suspend fun proposeSigningBatch( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + lead: EventTemplate<*>, + dependents: (lead: Event) -> List> + ): FrostSigningSession? { + val signedLead = signed(lead) + proposed = listOf(signedLead) + dependents(signedLead).map { signed(it) } + return session + } + + /** The template as a finished session leaves it: authored by the room. */ + private fun signed(template: EventTemplate<*>) = Event( + id = EventHasher.hashId( + pubKey = authorPublicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + ), + pubKey = authorPublicKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "f".repeat(128), + ) +}