Post a story that is only words, or a link
Android half of ios#32, spec §6.21. `att` becomes optional and the caption becomes the story. 🔴 The decode sits before the attachment branch, and that placement is the feature. The branch requires `att` for a story; a text story failing it falls through to the ordinary-message path and shows as a chat bubble in the conversation that never expires — both of the things §6.6 says a story must not be. There is a test named for it. `Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing can read a blob id off a story that has none. That forced a `key` for seen-marks and dedup, and an `expiresAt` that folds the two cases so no caller has to know which kind it is holding. ⚠️ The stored record omits `ref` entirely rather than writing an empty object — a reload would otherwise resurrect a blob that fails to fetch, and `loadStories` had to stop treating an absent ref as malformed. A "Write" entry on the Stories tab posts one, and says who enforces the expiry: there is no photo to delete, so it relies on the receiving app honouring `exp`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
021dc2c24d
commit
7c51bc2521
@@ -0,0 +1,115 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Stories that are only words, and the links in them (spec §6.21, ios#32). */
|
||||
class TextStoryTest {
|
||||
|
||||
@Test
|
||||
fun `a text story carries its caption and expiry`() {
|
||||
val content = Content.parse(Content.textStory("back Thursday", expires = 1788000000.0))
|
||||
assertTrue(content.isTextStory)
|
||||
assertEquals("back Thursday", content.text)
|
||||
assertEquals(1788000000.0, content.storyExpiry)
|
||||
}
|
||||
|
||||
/** ⚠️ No `att`: an empty attachment would be fetched, fail, and show broken. */
|
||||
@Test
|
||||
fun `a text story carries no attachment`() {
|
||||
val json = JSONObject(Content.textStory("hello", expires = 1.0))
|
||||
assertFalse(json.has("att"))
|
||||
assertEquals(Attachment.STORY, json.getString("kind"))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The decode must run BEFORE the attachment branch. If it does not, a
|
||||
* story with no `att` falls through to the ordinary-message path and shows
|
||||
* as a chat bubble that never expires — both things §6.6 forbids.
|
||||
*/
|
||||
@Test
|
||||
fun `a text story is never an ordinary message`() {
|
||||
val content = Content.parse("""{"fnc":1,"kind":"story","caption":"hi","exp":99}""")
|
||||
assertTrue(content.isTextStory)
|
||||
assertNull(content.attachment)
|
||||
}
|
||||
|
||||
/** A story with neither blob nor words is nothing, and is not a story. */
|
||||
@Test
|
||||
fun `a story with no attachment and no caption is not a text story`() {
|
||||
assertFalse(Content.parse("""{"fnc":1,"kind":"story","caption":"","exp":9}""").isTextStory)
|
||||
}
|
||||
|
||||
/** An unknown background falls back rather than refusing the story. */
|
||||
@Test
|
||||
fun `an unknown background still yields a story`() {
|
||||
val content = Content.parse(
|
||||
"""{"fnc":1,"kind":"story","caption":"hi","exp":99,"bg":9999}""")
|
||||
assertTrue(content.isTextStory)
|
||||
assertEquals(9999, content.storyBackground)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the marker stays first`() {
|
||||
assertTrue(Content.textStory("hi", expires = 1.0).startsWith("{\"fnc\":1"))
|
||||
}
|
||||
|
||||
// -- The model and its storage --------------------------------------------
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
val records = LinkedHashMap<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() = javax.crypto.KeyGenerator.getInstance("AES")
|
||||
.apply { init(256) }.generateKey()
|
||||
|
||||
private fun textStory(expires: Double) = Vault.Story(
|
||||
id = "s1", authorId = 7, mine = false, ref = null, caption = "hi",
|
||||
postedAt = 100, textExpiry = expires,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a text story expires on its top-level stamp`() {
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
assertFalse(textStory(now - 60).isActive(now))
|
||||
assertTrue(textStory(now + 60).isActive(now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a text story is marked as having no blob`() {
|
||||
assertTrue(textStory(0.0).isTextOnly)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The stored record must omit `ref` entirely rather than write an empty
|
||||
* object — a reload would otherwise resurrect a blob that fails to fetch.
|
||||
*/
|
||||
@Test
|
||||
fun `a text story round-trips through the vault without a ref`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(textStory(now + 600)), now)
|
||||
val back = vault.loadStories()
|
||||
assertEquals(1, back.size)
|
||||
assertNull(back[0].ref)
|
||||
assertEquals("hi", back[0].caption)
|
||||
assertTrue(back[0].isTextOnly)
|
||||
}
|
||||
|
||||
/** ⚠️ An expired text story must leave on write, like any other. */
|
||||
@Test
|
||||
fun `an expired text story is not written back`() {
|
||||
val vault = Vault(MapStorage(), key())
|
||||
val now = System.currentTimeMillis() / 1000.0
|
||||
vault.saveStories(listOf(textStory(now - 10)), now)
|
||||
assertTrue(vault.loadStories().isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user