Prove a group pin resolves (relay#46) #91
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user