diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt new file mode 100644 index 0000000..39f071c --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/GroupPinResolvesTest.kt @@ -0,0 +1,128 @@ +package io.flamenet.messenger.app + +import io.flamenet.client.MessengerApi +import io.flamenet.client.RelayClient +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Vault +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A pin in a GROUP resolves to the message it names (ยง6.19). + * + * ๐Ÿ”ด **Written to settle a published claim.** The spec says a group pin *"is + * carried correctly on the wire but cannot resolve yet, because a group + * transcript does not record"* a message id. `Session.sendGroup` mints a `mid` + * and passes it to `record(...)`, so the stated reason is already false โ€” but + * relay#46's rule is that a claim is flipped by a run, not by reading the code, + * and nothing exercised the group case. + * + * โš ๏ธ The 1:1 case was never in doubt. This is specifically about a group + * thread, because that is what the spec singles out. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class GroupPinResolvesTest { + + private lateinit var server: MockWebServer + + @BeforeTest fun start() { server = MockWebServer().also { it.start() } } + @AfterTest fun stop() { server.shutdown() } + + private class MapStorage : Vault.Storage { + private val records = mutableMapOf() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private fun key(): SecretKey = + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + + private val gid = "abc123" + private val thread = Vault.threadForGroup("abc123") + + private fun session(): Session { + val identity = DeviceIdentity.create() + val vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) } + val base = server.url("/").toString() + return Session("alice", 1, identity, vault, + RelayClient(base, "alice", "app pass word"), + MessengerApi(base, "alice", "app pass word")) + } + + @Test + fun `a group transcript records a message id`() { + val s = session() + s.record(thread, "in a group", mine = true, author = 1L, messageId = "gm1") + assertEquals("gm1", s.history(thread).single().id, + "a group line carries no id โ€” the spec's stated reason would hold") + } + + @Test + fun `a pin in a group thread resolves to the message it names`() { + val s = session() + s.record(thread, "worth keeping", mine = true, author = 1L, messageId = "gm1") + s.record(thread, "ordinary", mine = false, author = 2L, messageId = "gm2") + + s.applyPin(thread, "gm1") + val pinned = assertNotNull(s.pinnedMessage(thread), "nothing was pinned") + assertEquals("gm1", pinned) + + // ๐Ÿ”ด The resolve: the banner needs the MESSAGE, not just its id. + val target = s.history(thread).firstOrNull { it.id == pinned } + assertEquals("worth keeping", assertNotNull(target).text, + "the pin named a message the group transcript could not find") + } + + /** + * โš ๏ธ An id, never a copy. A stored copy would go stale the moment the + * message was edited and would survive it being deleted โ€” a banner quoting + * words no longer in the conversation. + */ + @Test + fun `the pin follows an edit rather than quoting the old words`() { + val s = session() + s.record(thread, "before", mine = false, author = 2L, messageId = "gm1") + s.applyPin(thread, "gm1") + s.applyEdit(thread, "gm1", author = 2L, newText = "after") + + val pinned = assertNotNull(s.pinnedMessage(thread)) + val target = s.history(thread).firstOrNull { it.id == pinned } + assertEquals("after", assertNotNull(target).text, + "the banner is quoting words that are no longer in the conversation") + } + + @Test + fun `taking the pin down leaves nothing pinned`() { + val s = session() + s.record(thread, "x", mine = true, author = 1L, messageId = "gm1") + s.applyPin(thread, "gm1") + s.applyPin(thread, "") + assertNull(s.pinnedMessage(thread)) + } + + /** A group's pin and a person's pin are separate threads, separately pinned. */ + @Test + fun `a group pin does not leak into a one-to-one thread`() { + val s = session() + val dm = Vault.threadForUser(2L) + s.record(thread, "group message", mine = true, author = 1L, messageId = "gm1") + s.record(dm, "direct message", mine = true, author = 1L, messageId = "dm1") + + s.applyPin(thread, "gm1") + assertEquals("gm1", s.pinnedMessage(thread)) + assertNull(s.pinnedMessage(dm), "pinning in a group pinned something in a DM") + } +}