From 2d0a5ce50fd1882cf60e87b39e3a1a18aa36e5a5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:00:51 +0200 Subject: [PATCH] feat(projects): three kinds, a creator tag, and the owner role the parser needs Phase 1 of docs/project-feature.md. Nothing emits these yet -- no screen, no repository, no apply arm -- so this is three kinds the app can build and parse and nothing else. The kinds are 30330-30332, not the next free number in the nip30303 band. That band is not merely full: 30310, 30311 and 30312 are double-booked with the ceremony family, survivable only because none of the three nip30303 kinds has a producer. 30317-30319 is the ceremony family's growth room. So projects start after the highest kind in use. A project names people two ways because they are two shapes. One creator, so `CreatorTag` -- a single-value tag like every other field here, and multi-character on purpose: NIP-01 indexes single-letter names, so a `p` tag is an address, and a broadcast project should carry its creator as data rather than as a recipient. Several owners, so `ContributorTag` with `ROLE_OWNER`, which is what that tag is for. `ROLE_OWNER` goes into `KNOWN_ROLES` in this same commit, which is the whole reason that line is first. `ContributorTag.parse` drops a role it does not know -- rightly, since a stale relay hint would otherwise render as a role -- and the consequence for an unregistered one is that the tag parses, the role comes back null, the string stays inside the signed event id, the event still verifies, and the project lands owned by nobody. Nothing is logged. `ProjectEventTest` has the three-line test that would say so. Two other things that fail quietly, both in `ProjectEvent.build`. `addUniqueValueIfNew` rather than `addUnique`, because `addUnique` keeps one tag per *name* and three owners would come out as one. And sorted, because the id is a hash over the tags as emitted and a multi-select hands its selection over in no particular order -- the same reason `DkgSession.participantPublicKeys` sorts. A membership is its own event rather than a line in a list the project carries. The only list-shaped kinds in this family are the three contributor lists, which are precisely the ones nothing applies; and one quorum per decision is the grain this app signs at, which a replaceable list would lose. `membershipsOf` mirrors `ArtifactVersionEvent.initialVersionOf`: built from the project *after* the session has authored it, since the id it carries is a hash over the group's key at the room's path and no screen can know it beforehand. Co-Authored-By: Claude Opus 5 --- .../nip30303/ProjectArtifactVersionEvent.kt | 92 ++++++++ .../compose/nostr/nip30303/ProjectEvent.kt | 107 +++++++++ .../ProjectTranslationArtifactVersionEvent.kt | 67 ++++++ .../nostr/nip30303/tags/ContributorTag.kt | 22 +- .../compose/nostr/nip30303/tags/CreatorTag.kt | 44 ++++ .../nostr/nip30303/tags/ProjectIdTag.kt | 44 ++++ .../nostr/nip30303/ProjectEventTest.kt | 207 ++++++++++++++++++ 7 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt new file mode 100644 index 00000000..071399fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectArtifactVersionEvent.kt @@ -0,0 +1,92 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ArtifactVersionIdTag +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag + + +/** + * One source version is part of one project. + * + * Its own event rather than a line in a list the project carries. Every entity + * in this family is its own event and its own row, and the only list-shaped kinds + * here -- the three contributor lists -- are precisely the ones nothing applies. + * A membership is also something a group decided, and one quorum per decision is + * the grain this app signs at: a replaceable list would make "we added this" and + * "we removed that" the same event. + * + * It carries no creator. Who moved a version into a project is a fact about + * process, which the `FrostSigningSession` records; a membership signed in the + * batch that founds a project has the project's creator anyway. + */ +@Immutable +class ProjectArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun artifactVersionIdReference() = tags.firstNotNullOfOrNull(ArtifactVersionIdTag::parse)?.ref + fun artifactVersionId() = artifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30331 + const val ALT_DESCRIPTION = "ProjectArtifactVersion" + + /** + * The source versions [project] starts life with, ready to be signed + * with it. + * + * A project and its first contents go to the group as one batch, so this + * is built from the project *after* it has been authored under the + * group's key -- [project] is the unsigned event the session will sign, + * which is where the id these carry comes from. Working it out anywhere + * else would mean naming a project id before one exists: it is a hash + * over the group's key at the room's path, and neither is resolved until + * the proposal is made. + * + * They take the project's own timestamp, so the batch reads as one act + * rather than several events that happen to share a session. + * + * The same shape as `ArtifactVersionEvent.initialVersionOf`, and empty + * for a project founded with no contents -- which is allowed, unlike an + * artifact with no version: an empty project is a name waiting for work. + */ + fun membershipsOf( + project: Event, + artifactVersionIds: List, + ): List> = + artifactVersionIds.map { artifactVersionId -> + build( + projectId = project.id, + artifactVersionId = artifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + artifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(ArtifactVersionIdTag.assemble(artifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt new file mode 100644 index 00000000..3fdc8d48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectEvent.kt @@ -0,0 +1,107 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import press.mantra.compose.nostr.nip30303.tags.NameTag + + +/** + * A body of work: the source versions to translate from and the translations + * being produced against them, under one name. + * + * A project names two kinds of person and they are not the same shape. There is + * one [creator], so it is a [CreatorTag] -- a single-value tag, like every other + * field here. There may be several [owners], so each is a [ContributorTag] with + * [ContributorTag.ROLE_OWNER], which is what that tag is for. + * + * Both are written before the event is proposed, so they are inside the id the + * quorum signs. That makes them the group's statement about whose work this is + * rather than one device's note about who inserted a row -- and it is a group + * attestation, not proof of authorship: the creator does not sign this, the room + * does, so a quorum that signs without reading has credited whoever the proposer + * typed. `ProposedEvent.summarize` shows both for that reason. + * + * Which group a project belongs to is not a tag at all. It is signed by the + * group's threshold key at the room's derivation path, so it says so by existing. + */ +@Immutable +class ProjectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun name() = tags.firstNotNullOfOrNull(NameTag::parse)?.name + + fun creator() = tags.firstNotNullOfOrNull(CreatorTag::parse)?.pubKey + + /** + * Everyone this project belongs to, in the order the event carries them. + * + * Filtered on the role rather than taking every `p` tag. Every contributor + * tag on a project is an owner today, and that is one future role away from + * being false -- at which point an unfiltered read would quietly hand back an + * owner list with somebody else in it. + * + * Empty is not a project this app wrote: [build] refuses it. It is what a + * reader sees when [ContributorTag.ROLE_OWNER] is missing from that tag's + * known roles, since the role then parses as null -- so treat empty as that + * bug rather than as a project nobody owns. + */ + fun owners() = tags.mapNotNull(ContributorTag::parse) + .filter { it.role == ContributorTag.ROLE_OWNER } + .map { it.pubKey } + + companion object { + const val KIND = 30330 + const val ALT_DESCRIPTION = "Project" + + /** + * [ownerPublicKeys] is a set and must not be empty. + * + * The caller always has an answer -- the room's own key, when the group + * owns what it makes -- and an empty set would make "owned by us" and + * "owner unstated" the same event, which leaves every reader needing to + * know the default. + */ + fun build( + name: String, + description: String, + creatorPublicKey: HexKey, + ownerPublicKeys: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + require(ownerPublicKeys.isNotEmpty()) { "A project has at least one owner" } + + alt(ALT_DESCRIPTION) + addUnique(NameTag.assemble(name)) + addUnique(CreatorTag.assemble(creatorPublicKey)) + + // Sorted, because the id is a hash over the tags as emitted and a + // multi-select hands its selection over in no particular order -- + // the same reason DkgSession.participantPublicKeys sorts. + // + // addUniqueValueIfNew, never addUnique: addUnique keeps one tag per + // *name*, so three owners would come out as one and the loss is + // silent. Deduping on the pubkey is the right thing for a set. + ownerPublicKeys.sorted().forEach { ownerPublicKey -> + addUniqueValueIfNew( + ContributorTag.assemble(ownerPublicKey, ContributorTag.ROLE_OWNER) + ) + } + + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt new file mode 100644 index 00000000..d2119485 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/ProjectTranslationArtifactVersionEvent.kt @@ -0,0 +1,67 @@ +package press.mantra.compose.nostr.nip30303 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils +import press.mantra.compose.nostr.nip30303.tags.ProjectIdTag +import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag + + +/** + * One translation is part of one project. + * + * The other half of [ProjectArtifactVersionEvent], and the same shape for the + * same reasons -- see there. + */ +@Immutable +class ProjectTranslationArtifactVersionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + + fun projectIdReference() = tags.firstNotNullOfOrNull(ProjectIdTag::parse)?.ref + fun projectId() = projectIdReference()?.eventId + + fun translationArtifactVersionIdReference() = + tags.firstNotNullOfOrNull(TranslationArtifactVersionIdTag::parse)?.ref + fun translationArtifactVersionId() = translationArtifactVersionIdReference()?.eventId + + companion object { + const val KIND = 30332 + const val ALT_DESCRIPTION = "ProjectTranslationArtifactVersion" + + /** The translations [project] starts life with. See the sibling's note. */ + fun membershipsOf( + project: Event, + translationArtifactVersionIds: List, + ): List> = + translationArtifactVersionIds.map { translationArtifactVersionId -> + build( + projectId = project.id, + translationArtifactVersionId = translationArtifactVersionId, + createdAt = project.createdAt, + ) + } + + fun build( + projectId: HexKey, + translationArtifactVersionId: HexKey, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + addUnique(ProjectIdTag.assemble(projectId)) + addUnique(TranslationArtifactVersionIdTag.assemble(translationArtifactVersionId)) + initializer() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt index d73e9e3e..f01ffdbf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ContributorTag.kt @@ -20,7 +20,27 @@ class ContributorTag( const val ROLE_PROOF_READER = "proofreader" const val ROLE_EDITOR = "editor" - private val KNOWN_ROLES = setOf(ROLE_TRANSLATOR, ROLE_PROOF_READER, ROLE_EDITOR) + /** + * Whoever the work belongs to, which need not be whoever is doing it. + * + * A project carries one of these per owner -- the room's own key when the + * group owns what it makes, and somebody else's when it does not. There + * is no matching creator role: a project has exactly one creator and a + * single-valued field gets a tag of its own, which is `CreatorTag`. + */ + const val ROLE_OWNER = "owner" + + // Four, where NIP-F4 defines host/cohost/editor. A role missing from this + // set is the quietest bug in the family: `parse` returns the tag with a + // null role, the string stays inside the signed event id, the event still + // verifies, and whatever was reading for that role finds nothing. Add to + // both halves or to neither. + private val KNOWN_ROLES = setOf( + ROLE_TRANSLATOR, + ROLE_PROOF_READER, + ROLE_EDITOR, + ROLE_OWNER, + ) fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt new file mode 100644 index 00000000..a3868e74 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/CreatorTag.kt @@ -0,0 +1,44 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Whoever started the thing this tag is on. + * + * A tag of its own rather than a [ContributorTag] with a creator role, because + * there is exactly one creator and a single-valued field is what every other tag + * in this package is. The owners are the other shape -- a set, open-ended, and so + * [ContributorTag] with [ContributorTag.ROLE_OWNER]. + * + * The name is multi-character on purpose. NIP-01 indexes single-letter tag names + * and makes them queryable, so a `p` tag is an address: relays index it and a + * client may read it as a mention. These events live inside a Marmot group and + * are never on a relay as themselves, but `BroadcastGroupSignedEventScreen` can + * put one there, and a creator should travel in that as data rather than as a + * recipient. + */ +class CreatorTag( + val pubKey: HexKey, +) { + fun toTagArray() = assemble(pubKey = pubKey) + + companion object { + const val TAG_NAME = "creator" + + fun parse(tag: Array): CreatorTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + // Without this a malformed value parses, lands on the row, and names + // nobody. ContributorTag checks the same thing for the same reason. + ensure(tag[1].length == 64) { return null } + + return CreatorTag(pubKey = tag[1]) + } + + fun assemble(pubKey: HexKey): Array = arrayOf(TAG_NAME, pubKey) + + fun assemble(creatorTag: CreatorTag) = assemble(pubKey = creatorTag.pubKey) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt new file mode 100644 index 00000000..a5817693 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/nip30303/tags/ProjectIdTag.kt @@ -0,0 +1,44 @@ +package press.mantra.compose.nostr.nip30303.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +data class ProjectIdTag( + val ref: EventReference, +) { + constructor(eventId: String, relayHint: NormalizedRelayUrl?, pubkey: String?) : this( + EventReference(eventId, pubkey, relayHint) + ) + + companion object { + // Plain, like `artifactId`. Six tags in this package carry a stray `Tag` + // suffix in the wire value -- `chapterIdTag` and friends -- which is a + // copy-paste slip and is unfixable: those strings are inside event ids + // the group has signed, so renaming one invalidates every signature over + // it. Do not copy it and do not tidy it. + const val TAG_NAME = "projectId" + + fun parse(tag: Array): ProjectIdTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return ProjectIdTag(tag[1], relayHint, tag.getOrNull(3)) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl? = null, + pubkey: String? = null, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, pubkey) + + fun assemble(ref: EventReference) = assemble(ref.eventId, ref.relayHint, ref.author) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt new file mode 100644 index 00000000..e0c2fea9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/nip30303/ProjectEventTest.kt @@ -0,0 +1,207 @@ +package press.mantra.compose.nostr.nip30303 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import press.mantra.compose.nostr.nip30303.tags.ContributorTag +import press.mantra.compose.nostr.nip30303.tags.CreatorTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A project names people two different ways, and both ways fail quietly. + * + * The creator is a [CreatorTag] and the owners are [ContributorTag]s with + * [ContributorTag.ROLE_OWNER]. Each has a failure mode that produces a valid, + * verifiable event carrying nobody: + * + * - a role missing from `ContributorTag`'s known set parses as null, so every + * owner vanishes while the role string stays inside the signed id; + * - `addUnique` keeps one tag per *name*, so three owners emitted with it + * become one, which reads like a bug in whatever collected them. + * + * Neither logs anything. These tests are the only thing that would say. + */ +class ProjectEventTest { + private val creator = "a".repeat(64) + private val owner1 = "1".repeat(64) + private val owner2 = "2".repeat(64) + private val owner3 = "3".repeat(64) + + private fun EventTemplate.asEvent() = ProjectEvent( + id = "e".repeat(64), + pubKey = creator, + createdAt = createdAt, + tags = tags, + content = content, + sig = "" + ) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "The four chapters, with the Setswana back-translation.", + creatorPublicKey = creator, + ownerPublicKeys = owners + ).asEvent() + + // --- the role has to be known, or there are no owners at all --- + + @Test + fun `the owner role survives a round trip through ContributorTag`() { + val parsed = ContributorTag.parse( + ContributorTag.assemble(owner1, ContributorTag.ROLE_OWNER) + ) + + assertEquals(owner1, parsed?.pubKey) + // If ROLE_OWNER is ever dropped from KNOWN_ROLES this is the only place + // that says so. Everything downstream just sees a project with no owners. + assertEquals(ContributorTag.ROLE_OWNER, parsed?.role) + } + + // --- the creator tag --- + + @Test + fun `a creator tag round trips`() { + assertEquals(creator, CreatorTag.parse(CreatorTag.assemble(creator))?.pubKey) + } + + @Test + fun `a creator tag whose value is not a key is refused`() { + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME, "not-a-key"))) + assertNull(CreatorTag.parse(arrayOf(CreatorTag.TAG_NAME))) + assertNull(CreatorTag.parse(arrayOf("p", creator))) + } + + // --- owners are a set, and all of them survive --- + + @Test + fun `three owners come back, sorted`() { + val event = project(setOf(owner3, owner1, owner2)) + + // `addUnique` here would leave one. Sorted because the id is a hash over + // the tags as emitted and a form hands its selection over in no + // particular order. + assertEquals(listOf(owner1, owner2, owner3), event.owners()) + } + + @Test + fun `an owner listed twice appears once`() { + assertEquals(listOf(owner1), project(setOf(owner1)).owners()) + } + + @Test + fun `a project with no owners cannot be built`() { + assertFailsWith { + ProjectEvent.build( + name = "Nobody's", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = emptySet() + ) + } + } + + @Test + fun `owners ignores a contributor tag that is not an owner`() { + val event = ProjectEvent.build( + name = "Gospel of John into Sesotho", + description = "", + creatorPublicKey = creator, + ownerPublicKeys = setOf(owner1) + ) { + addUniqueValueIfNew( + ContributorTag.assemble(owner2, ContributorTag.ROLE_EDITOR) + ) + }.asEvent() + + assertEquals(listOf(owner1), event.owners()) + } + + // --- the creator and the owners are different tags, so one can be both --- + + @Test + fun `the creator may also be an owner`() { + val event = project(setOf(creator, owner1)) + + assertEquals(creator, event.creator()) + assertTrue(creator in event.owners()) + assertTrue(owner1 in event.owners()) + } + + @Test + fun `the creator is not read off the owner tags`() { + val event = project(setOf(owner1)) + + // Selecting the creator by position among p-tags would make these agree, + // which reads as working. + assertEquals(creator, event.creator()) + assertEquals(listOf(owner1), event.owners()) + } + + @Test + fun `name and description land where they are read from`() { + val event = project(setOf(owner1)) + + assertEquals("Gospel of John into Sesotho", event.name()) + assertTrue(event.content.startsWith("The four chapters")) + } + + // --- memberships borrow the project's id and timestamp --- + + @Test + fun `memberships name the project and share its timestamp`() { + val signedProject: Event = project(setOf(owner1)) + val versionId = "f".repeat(64) + + val sources = ProjectArtifactVersionEvent.membershipsOf( + project = signedProject, + artifactVersionIds = listOf(versionId) + ) + val translations = ProjectTranslationArtifactVersionEvent.membershipsOf( + project = signedProject, + translationArtifactVersionIds = listOf(versionId) + ) + + val source = ProjectArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = sources.single().createdAt, + tags = sources.single().tags, + content = sources.single().content, + sig = "" + ) + + assertEquals(signedProject.id, source.projectId()) + assertEquals(versionId, source.artifactVersionId()) + assertEquals(signedProject.createdAt, source.createdAt) + + val translation = ProjectTranslationArtifactVersionEvent( + id = "0".repeat(64), + pubKey = creator, + createdAt = translations.single().createdAt, + tags = translations.single().tags, + content = translations.single().content, + sig = "" + ) + + assertEquals(signedProject.id, translation.projectId()) + assertEquals(versionId, translation.translationArtifactVersionId()) + assertEquals(signedProject.createdAt, translation.createdAt) + } + + @Test + fun `a project founded with no contents has no memberships`() { + val signedProject: Event = project(setOf(owner1)) + + assertTrue( + ProjectArtifactVersionEvent.membershipsOf(signedProject, emptyList()).isEmpty() + ) + assertTrue( + ProjectTranslationArtifactVersionEvent + .membershipsOf(signedProject, emptyList()) + .isEmpty() + ) + } +}