Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/StoryAndMentionInteropTest.kt
T
Paul HittandClaude Opus 4.8 bfe9fff430 Understand stories and mentions from iOS
Engine parity for the five features iOS regained: text stories, mentions,
story reactions, story replies. This build could already receive them —
legibly, because each degrades to its caption — but understood none of
them as what they are.

🔴 Every envelope in the test is the shape **iOS** assembles, not this
engine's own output. Two implementations that each only test against
themselves agree right up to the day somebody sends a real message, and
the symptom is a mention that never rings or a story that arrives as
"[unsupported attachment]", with nothing in either log to say why.

Two things the tests caught that reasoning would not have:

⚠️ Kotlin prints a Double in scientific notation, so this engine writes
`"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
both sides read either — but a story's expiry decides whether it shows at
all, and "arrived already expired" is indistinguishable from "never
arrived". Now pinned on both sides. Not a new risk: picture stories have
carried `exp` this way all along, which is the reason it works.

⚠️ An earlier version of one test built a JSONObject from an assembled
envelope and re-serialised it — which reordered the keys and moved `fnc`
off the front. It failed, correctly, and is a neat demonstration of why
both engines hand-assemble these strings instead of encoding a map.

UI comes next; this is the layer that decides whether the two apps agree
about what a message means.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 14:15:11 -04:00

172 lines
6.7 KiB
Kotlin

package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Stories and mentions, against envelopes the **iOS** build produces.
*
* 🔴 Every string below is the shape iOS assembles, not this engine's own
* output. Two implementations that each only test against themselves agree
* right up to the day somebody sends a real message — and the symptom is a
* mention that never rings, or a story that arrives as "[unsupported
* attachment]", with nothing in either log to say why.
*/
class StoryAndMentionInteropTest {
// MARK: text stories
private val iosTextStory =
"""{"fnc":1,"kind":"story","mid":"story-1","caption":"only words","exp":1800000000.0}"""
@Test
fun `a text story from iOS parses as a story rather than an attachment`() {
val content = Content.parse(iosTextStory)
assertEquals("only words", content.text)
assertEquals(1800000000.0, content.textStoryExpires)
assertEquals(null, content.attachment)
assertEquals("story-1", content.messageId)
}
@Test
fun `this engine assembles a text story the same way`() {
val wire = Content.textStory("hello", 1800000000.0, "m-1")
// ⚠️ Asserted on the WIRE STRING, not on a JSONObject built from it.
// Round-tripping through JSONObject reorders the keys and puts `fnc`
// somewhere other than first — which is exactly the hazard that makes
// both engines hand-assemble these strings. An earlier version of this
// test did that and failed, correctly.
assertTrue(wire.startsWith("{\"fnc\":1"), wire)
assertTrue(wire.contains("\"kind\":\"story\""), wire)
assertTrue(wire.contains("\"caption\":\"hello\""), wire)
assertEquals("hello", Content.parse(wire).text)
}
/**
* ⚠️ Kotlin prints a Double in scientific notation, so this engine writes
* `"exp":1.8E9` where iOS writes `1800000000.0`. Both are valid JSON and
* both sides read either — but a story's expiry decides whether it shows
* at all, so the shape is pinned here rather than assumed. (This is not
* new: picture stories have carried `exp` this way all along.)
*/
@Test
fun `an expiry written in scientific notation reads back`() {
val wire = Content.textStory("x", 1800000000.0, "m")
assertTrue(wire.contains("\"exp\":1.8E9"), wire)
assertEquals(1800000000.0, Content.parse(wire).textStoryExpires)
}
/** 🔴 A picture story must be completely unaffected. */
@Test
fun `a picture story still parses as an attachment`() {
val ref = Attachment.Ref(
kind = Attachment.STORY, id = "a".repeat(64),
key = ByteArray(32) { 0x11 }, digest = ByteArray(32) { 0x22 },
bytes = 10, mime = "image/jpeg", width = 4, height = 3, expires = 1800000000.0,
)
val content = Content.parse(Content.attachment(ref, "look"))
assertNotNull(content.attachment)
assertEquals(null, content.textStoryExpires)
}
// MARK: mentions
@Test
fun `mentions from iOS arrive as ids`() {
val ios = """{"fnc":1,"kind":"text","caption":"morning @Ada","gid":"g1","men":[3,7]}"""
val content = Content.parse(ios)
assertEquals(listOf(3, 7), content.mentions)
assertEquals("g1", content.groupId)
}
@Test
fun `this engine emits mentions the same way`() {
val json = Content.groupText("hi @Ada", "g1", mentions = listOf(7, 3))
assertTrue(json.contains("\"men\":[3,7]"), json)
assertEquals(listOf(3, 7), Content.parse(json).mentions)
}
/** ⚠️ A message that merely spells a name carries no mention. */
@Test
fun `spelling a name is not a mention`() {
val json = Content.groupText("hello @Ada", "g1")
assertFalse(json.contains("\"men\""))
assertTrue(Content.parse(json).mentions.isEmpty())
}
// MARK: story reactions
@Test
fun `a story reaction from iOS is not mistaken for a message reaction`() {
val ios = """{"fnc":1,"kind":"react","caption":"❤️","re":{"mid":"s-1","author":42,"preview":""},"st":1}"""
val content = Content.parse(ios)
assertTrue(content.reactsToStory)
assertEquals("❤️", content.reaction)
assertEquals("s-1", content.about?.messageId)
}
/**
* 🔴 The separation that matters. Both are `kind: "react"`; without the
* flag a story reaction folds onto a message with that id — and if one
* ever matched, onto entirely the wrong thing.
*/
@Test
fun `an ordinary message reaction is not read as a story one`() {
val json = Content.reaction("👍", Content.Reference("m-1", 5, ""))
val content = Content.parse(json)
assertFalse(content.reactsToStory)
assertFalse(json.contains("\"st\""))
}
@Test
fun `this engine emits a story reaction the same way`() {
val json = Content.storyReaction("😂", "s-2", 9L)
assertTrue(json.contains("\"st\":1"), json)
val content = Content.parse(json)
assertTrue(content.reactsToStory)
assertEquals("😂", content.reaction)
}
// MARK: story replies
@Test
fun `a story reply from iOS keeps what it is about`() {
val ios = """{"fnc":1,"kind":"text","mid":"m-9","caption":"lovely","re":{"mid":"s-3","author":1,"preview":"at the beach"},"st":1}"""
val content = Content.parse(ios)
assertTrue(content.repliesToStory)
assertEquals("lovely", content.text)
assertEquals("at the beach", content.about?.preview)
}
@Test
fun `an ordinary reply is not a story reply`() {
val json = Content.text("sure", messageId = "m-1",
about = Content.Reference("other", 2, "earlier"))
assertFalse(Content.parse(json).repliesToStory)
}
@Test
fun `this engine emits a story reply the same way`() {
val json = Content.storyReply("nice", "s-4", 3L, "sunset", "m-2")
val content = Content.parse(json)
assertTrue(content.repliesToStory)
assertEquals("sunset", content.about?.preview)
assertEquals("nice", content.text)
}
/** 🔴 fnc stays first, whatever else was added. */
@Test
fun `every envelope this engine builds starts with fnc`() {
listOf(
Content.textStory("x", 1.0, "m"),
Content.storyReaction("👍", "s", 1L),
Content.storyReply("hi", "s", 1L, "p", "m"),
Content.groupText("hi", "g", mentions = listOf(1)),
).forEach { assertTrue(it.startsWith("{\"fnc\":1"), it) }
}
}