Prove a group pin resolves (relay#46) #91

Merged
paulhitt merged 1 commits from test/group-pin-resolves into main 2026-09-05 00:42:43 +00:00
@@ -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<String, ByteArray>()
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")
}
}