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), + ) +}