merge: W19 — 单条消息的编辑与删除(D3)
This commit is contained in:
commit
ea16f48be2
13 files changed
+1717
-15
No files matched your search
@@ -0,0 +1,169 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* D3 的**数据层**:`PATCH` / `DELETE /api/v1/messages/{id}` 真的发出去的是什么。
|
||||
*
|
||||
* 用 JDK 自带的 `com.sun.net.httpserver` 跑真 OkHttp + 真 Retrofit(与 `MessageSearchRepositoryTest`
|
||||
* / `ChatRepositoryTest` 同一条做法,不引新依赖),断言落在**方法 + 原始路径 + 原始请求体**上:
|
||||
*
|
||||
* - 契约 §1 行 6a 的响应是 `{data:<消息DTO>}`——`data` **直接就是**那条消息,**没有** `message`
|
||||
* 包一层。这里两条都试:裸 DTO 必须解出来,包一层的旧形状必须解不出来(免得将来有人
|
||||
* 「顺手兼容」把契约放回旧形状);
|
||||
* - 契约 §1 行 6b 的响应是 `{data:{deleted:<id>}}`;
|
||||
* - 空 / 纯空白 / 超 4000 rune 的内容**一个请求都不发**(服务端必回 400,白等一个来回);
|
||||
* - `404 not_found` 必须带得出原因(上层要如实说「这条已经被别的端删了」)。
|
||||
*/
|
||||
class MessageEditRepositoryTest {
|
||||
|
||||
private val backend = FakeMessagesBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = MessageRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `edit sends PATCH to the message endpoint with only content in the body`() = runBlocking {
|
||||
backend.reply(200, """{"data":${message(id = "m1", content = "改过的内容")}}""")
|
||||
|
||||
val updated = repository().editMessage("m1", "改过的内容").getOrThrow()
|
||||
|
||||
assertEquals("PATCH", backend.methods.single())
|
||||
assertEquals("/api/v1/messages/m1", backend.uris.single())
|
||||
// 只发 content 一个键:多带 role / 时间戳只会让后来的人以为客户端能改这些。
|
||||
assertEquals("""{"content":"改过的内容"}""", backend.bodies.single())
|
||||
assertEquals("m1", updated.id)
|
||||
assertEquals("改过的内容", updated.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the edit response is the bare message dto, without any message wrapper`() = runBlocking {
|
||||
// 旧文(v0.30 变更记录)写的是 {data:{message}} —— 以契约正文为准,那是**错的**形状。
|
||||
backend.reply(200, """{"data":{"message":${message(id = "m1", content = "x")}}}""")
|
||||
|
||||
val result = repository().editMessage("m1", "x")
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete sends DELETE to the message endpoint and returns the deleted id`() = runBlocking {
|
||||
backend.reply(200, """{"data":{"deleted":"m1"}}""")
|
||||
|
||||
val deleted = repository().deleteMessage("m1").getOrThrow()
|
||||
|
||||
assertEquals("DELETE", backend.methods.single())
|
||||
assertEquals("/api/v1/messages/m1", backend.uris.single())
|
||||
assertEquals("", backend.bodies.single())
|
||||
assertEquals("m1", deleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank content never hits the network`() = runBlocking {
|
||||
val result = repository().editMessage("m1", " ")
|
||||
|
||||
assertTrue(backend.uris.isEmpty())
|
||||
val error = (result.exceptionOrNull() as CyreneException).error as CyreneError.Server
|
||||
assertEquals(400, error.httpStatus)
|
||||
assertEquals("消息内容不能为空", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `content over the rune limit never hits the network either`() = runBlocking {
|
||||
val result = repository().editMessage("m1", "a".repeat(MESSAGE_EDIT_MAX_RUNES + 1))
|
||||
|
||||
assertTrue(backend.uris.isEmpty())
|
||||
val error = (result.exceptionOrNull() as CyreneException).error as CyreneError.Server
|
||||
assertEquals(400, error.httpStatus)
|
||||
// 文案里要带真实数字,用户才知道该删多少。
|
||||
assertTrue(error.message, error.message.contains("${MESSAGE_EDIT_MAX_RUNES}"))
|
||||
assertTrue(error.message, error.message.contains("${MESSAGE_EDIT_MAX_RUNES + 1}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing message surfaces as a 404 the caller can explain`() = runBlocking {
|
||||
backend.reply(404, """{"error":{"code":"not_found","message":"message not found"}}""")
|
||||
|
||||
val error = (repository().editMessage("m1", "x").exceptionOrNull() as CyreneException)
|
||||
.error as CyreneError.Server
|
||||
|
||||
assertEquals(404, error.httpStatus)
|
||||
assertEquals("not_found", error.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed delete surfaces as a failure instead of a silent success`() = runBlocking {
|
||||
backend.reply(501, """{"error":{"code":"not_implemented","message":"history store unavailable"}}""")
|
||||
|
||||
val error = (repository().deleteMessage("m1").exceptionOrNull() as CyreneException)
|
||||
.error as CyreneError.Server
|
||||
|
||||
assertEquals(501, error.httpStatus)
|
||||
assertFalse(error.userMessage.isBlank())
|
||||
}
|
||||
|
||||
private fun message(id: String, content: String): String =
|
||||
"""{"id":"$id","sessionId":"web","role":"user","content":"$content","createdAt":1753000000000}"""
|
||||
|
||||
/** 只回一份固定响应,但把每次请求的方法 / 原始 URI / 请求体都记下来的假后端。 */
|
||||
private inner class FakeMessagesBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val methods = CopyOnWriteArrayList<String>()
|
||||
val uris = CopyOnWriteArrayList<String>()
|
||||
val bodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
@Volatile
|
||||
private var status: Int = 200
|
||||
|
||||
@Volatile
|
||||
private var body: String = """{"data":{}}"""
|
||||
|
||||
fun reply(status: Int, body: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
methods += exchange.requestMethod
|
||||
uris += exchange.requestURI.toString()
|
||||
// 请求体必须先读完(否则响应发不出去),删 / 改的体都很小。
|
||||
bodies += exchange.requestBody.use { it.readBytes().toString(Charsets.UTF_8) }
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.ws.WsEvent
|
||||
|
||||
/**
|
||||
* D3 的**纯逻辑**:编辑内容的准入、按 `id` 原位改 / 移除,以及「双到达只收敛一次」。
|
||||
*
|
||||
* 这一层是编辑/删除的**唯一落地实现**(VM 的两条路都调它们),所以「广播 + REST 响应都到」
|
||||
* 这件事不需要去重表、也不该在 VM 里再写第二套——它由这里的幂等性直接保证:
|
||||
* 同一个内容做两次与做一次**返回同一个实例**,UI 不会看到两次变更,Compose 的 key 也稳。
|
||||
*/
|
||||
class MessageEditsTest {
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
role: String = "user",
|
||||
content: String = id,
|
||||
createdAt: Long = 100L,
|
||||
thinking: String? = null,
|
||||
image: String? = null,
|
||||
) = HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = content,
|
||||
thinking = thinking,
|
||||
image = image,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
// —— 准入:≤4000 rune、空白不提交 ——
|
||||
|
||||
@Test
|
||||
fun `a normal edit is accepted verbatim, without trimming`() {
|
||||
val raw = " 改过的内容 "
|
||||
|
||||
val validation = validateMessageEdit(raw)
|
||||
|
||||
// 服务端存的就是用户输入的那个字符串;客户端替它 trim 等于悄悄改掉用户的消息
|
||||
// (对代码块与 Markdown 引用尤其明显)。
|
||||
assertEquals(MessageEditDraft.Accepted(raw), validation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank and whitespace only content is never submitted`() {
|
||||
assertEquals(MessageEditDraft.Empty, validateMessageEdit(""))
|
||||
assertEquals(MessageEditDraft.Empty, validateMessageEdit(" "))
|
||||
assertEquals(MessageEditDraft.Empty, validateMessageEdit("\n\t "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly four thousand runes is allowed and one more is not`() {
|
||||
val max = "a".repeat(MESSAGE_EDIT_MAX_RUNES)
|
||||
val over = "a".repeat(MESSAGE_EDIT_MAX_RUNES + 1)
|
||||
|
||||
assertTrue(validateMessageEdit(max) is MessageEditDraft.Accepted)
|
||||
assertEquals(
|
||||
MessageEditDraft.TooLong(MESSAGE_EDIT_MAX_RUNES + 1),
|
||||
validateMessageEdit(over),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the limit counts code points, not utf16 units`() {
|
||||
// 4000 个 emoji:`length` 是 8000,但服务端数的是 4000 个 rune —— 按 length 卡会**误伤**,
|
||||
// 让用户明明还能发的消息发不出去。
|
||||
val emoji = "😀".repeat(MESSAGE_EDIT_MAX_RUNES)
|
||||
|
||||
assertEquals(MESSAGE_EDIT_MAX_RUNES * 2, emoji.length)
|
||||
assertEquals(MESSAGE_EDIT_MAX_RUNES, runeCount(emoji))
|
||||
assertTrue(validateMessageEdit(emoji) is MessageEditDraft.Accepted)
|
||||
|
||||
val over = "😀".repeat(MESSAGE_EDIT_MAX_RUNES + 1)
|
||||
assertEquals(MessageEditDraft.TooLong(MESSAGE_EDIT_MAX_RUNES + 1), validateMessageEdit(over))
|
||||
}
|
||||
|
||||
// —— 原位改:只改 content,找不到就忽略 ——
|
||||
|
||||
@Test
|
||||
fun `an edit changes the content in place and keeps every other field`() {
|
||||
val before = listOf(
|
||||
message("m1", content = "原内容", thinking = "我在想", image = "http://x/y.png"),
|
||||
message("m2", role = "assistant", content = "她的回复"),
|
||||
)
|
||||
|
||||
val after = applyMessageEdit(before, "m1", "改过的内容")
|
||||
|
||||
assertEquals(listOf("m1", "m2"), after.map { it.id })
|
||||
assertEquals("改过的内容", after[0].content)
|
||||
// role / 时间戳 / 图片 / 思维链都不动:契约明说这个操作只改 content,而实时帧的
|
||||
// thinking 恒为空——整条替换会把本端已渲染的思维链擦掉。
|
||||
assertEquals("user", after[0].role)
|
||||
assertEquals(100L, after[0].createdAt)
|
||||
assertEquals("http://x/y.png", after[0].image)
|
||||
assertEquals("我在想", after[0].thinking)
|
||||
assertEquals("她的回复", after[1].content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit for an unknown id is ignored instead of inserting a row`() {
|
||||
val before = listOf(message("m1"))
|
||||
|
||||
val after = applyMessageEdit(before, "nope", "别的会话的消息")
|
||||
|
||||
// 契约 §3.8:找不到该 id 即忽略,避免把别的会话的消息注入当前列表。
|
||||
assertSame(before, after)
|
||||
assertEquals(1, after.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank content or blank id is ignored instead of blanking a bubble`() {
|
||||
val before = listOf(message("m1"))
|
||||
|
||||
// 服务端拒空内容,合法编辑的结果不可能为空 ⇒ 空白是坏帧,宁可整帧忽略也不擦掉气泡正文。
|
||||
assertSame(before, applyMessageEdit(before, "m1", ""))
|
||||
assertSame(before, applyMessageEdit(before, "m1", " "))
|
||||
assertSame(before, applyMessageEdit(before, "", "新内容"))
|
||||
assertSame(before, applyMessageEdit(before, " ", "新内容"))
|
||||
}
|
||||
|
||||
// —— 原位移除 ——
|
||||
|
||||
@Test
|
||||
fun `a delete removes exactly the matching row`() {
|
||||
val before = listOf(message("m1"), message("m2", role = "assistant"))
|
||||
|
||||
val after = removeMessageById(before, "m1")
|
||||
|
||||
assertEquals(listOf("m2"), after.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a delete for an unknown id or a blank id changes nothing`() {
|
||||
val before = listOf(message("m1"))
|
||||
|
||||
assertSame(before, removeMessageById(before, "m2"))
|
||||
// 坏帧(缺 id)解出空串:删掉所有 id 为空的行不是任何人想要的结果。
|
||||
assertSame(before, removeMessageById(before, ""))
|
||||
assertSame(before, removeMessageById(before, " "))
|
||||
}
|
||||
|
||||
// —— 双到达只收敛一次 ——
|
||||
|
||||
@Test
|
||||
fun `the rest response and the broadcast together change the list exactly once`() {
|
||||
val before = listOf(message("m1", content = "原内容"), message("m2", role = "assistant", content = "她的回复"))
|
||||
// 客户端自己发起的 PATCH:响应先到,随后服务端广播同一件事。
|
||||
val response = HistoryMessageDto(
|
||||
id = "m1",
|
||||
sessionId = "web",
|
||||
role = "user",
|
||||
content = "改过的内容",
|
||||
createdAt = 100L,
|
||||
)
|
||||
val broadcast = broadcastEdit("m1", "改过的内容")
|
||||
|
||||
val afterResponse = applyMessageEdit(before, response.id, response.content)
|
||||
val afterBoth = applyMessageEdit(afterResponse, broadcast.id, broadcast.content)
|
||||
|
||||
assertEquals(listOf("改过的内容", "她的回复"), afterBoth.map { it.content })
|
||||
assertEquals(2, afterBoth.size)
|
||||
// 第二次是**同一个实例**:这就是「收敛成一次」的可观察形式(没有第二套状态、没有重复行)。
|
||||
assertSame(afterResponse, afterBoth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the broadcast arriving first converges the same way`() {
|
||||
val before = listOf(message("m1", content = "原内容"))
|
||||
val broadcast = broadcastEdit("m1", "改过的内容")
|
||||
|
||||
val afterBroadcast = applyMessageEdit(before, broadcast.id, broadcast.content)
|
||||
val afterBoth = applyMessageEdit(afterBroadcast, "m1", "改过的内容")
|
||||
|
||||
assertEquals(listOf("改过的内容"), afterBoth.map { it.content })
|
||||
assertSame(afterBroadcast, afterBoth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a delete response and its broadcast remove the row once`() {
|
||||
val before = listOf(message("m1"), message("m2", role = "assistant"))
|
||||
|
||||
val afterResponse = removeMessageById(before, "m1")
|
||||
val afterBoth = removeMessageById(afterResponse, "m1")
|
||||
|
||||
assertEquals(listOf("m2"), afterBoth.map { it.id })
|
||||
assertSame(afterResponse, afterBoth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another device deleting a message we never loaded changes nothing`() {
|
||||
val before = listOf(message("m1"))
|
||||
|
||||
// 广播是发给每条连接的(hub 没有按会话订阅),本会话列表里没有那个 id 就该原样不动。
|
||||
assertSame(before, removeMessageById(before, "m-elsewhere"))
|
||||
}
|
||||
|
||||
/** 用**真解析**出来的 `message_edited` 帧做收敛测试,而不是手搓一个 DTO——解析层也是本模块的一部分。 */
|
||||
private fun broadcastEdit(id: String, content: String): HistoryMessageDto {
|
||||
val raw = """{"type":"message_edited","sessionId":"web","data":""" +
|
||||
"""{"id":"$id","sessionId":"web","role":"user","content":"$content","createdAt":100}}"""
|
||||
val event = WsEvent.parse(raw) as WsEvent.MessageEdited
|
||||
return requireNotNull(event.message)
|
||||
}
|
||||
|
||||
// —— 产品口径:谁能编辑、谁能删 ——
|
||||
|
||||
@Test
|
||||
fun `only user messages are editable`() {
|
||||
assertTrue(canEditMessage(message("m1", role = "user")))
|
||||
assertFalse(canEditMessage(message("m2", role = "assistant")))
|
||||
// 未知 role 是开放集合里的东西:不认识就不改(fail-closed)。
|
||||
assertFalse(canEditMessage(message("m3", role = "system")))
|
||||
assertFalse(canEditMessage(message("m4", role = "")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user and assistant messages are both deletable, unknown roles are not`() {
|
||||
assertTrue(canDeleteMessage(message("m1", role = "user")))
|
||||
// 删掉一句错答是常见需求,服务端对这个端点也不分角色。
|
||||
assertTrue(canDeleteMessage(message("m2", role = "assistant")))
|
||||
assertFalse(canDeleteMessage(message("m3", role = "system")))
|
||||
assertFalse(canDeleteMessage(message("m4", role = "")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* D3 下行帧 `message_edited` / `message_deleted` 的解码(契约 §3.8)。
|
||||
*
|
||||
* 帧形状取自契约正文,不是手写猜的:
|
||||
*
|
||||
* - `message_edited`:`{type:"message_edited", sessionId, data:<编辑后的完整消息DTO>}`
|
||||
* - `message_deleted`:`{type:"message_deleted", sessionId, data:{id}}`
|
||||
*
|
||||
* 两者都复用 `message_persisted` 的同一条**全连接广播**路径,所以四条要害在这里钉死:
|
||||
* 1. 会话归属先取帧壳,帧壳没有时 `message_edited` 回落消息自己的 `sessionId`(删除帧没有
|
||||
* 第二份可取,只能按全局唯一的 `id` 兜底——那是契约允许的方式,由上层实现);
|
||||
* 2. 未知字段 / 未知键要安全(`ignoreUnknownKeys`,契约仍在演进);
|
||||
* 3. 坏帧(缺 `data`、非 JSON、未知 `type`)一律 `null` 或空值,**永不抛**;
|
||||
* 4. 这两帧同属「仅 owner + admin」那档(§3 角色门),但这层不做角色裁决,只做解析。
|
||||
*/
|
||||
class WsEventMessageEditTest {
|
||||
|
||||
private fun parse(raw: String): WsEvent =
|
||||
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
|
||||
|
||||
private fun editedFrame(
|
||||
shellSession: String = """"sessionId":"web",""",
|
||||
data: String,
|
||||
): String = """{"type":"message_edited",$shellSession"data":$data}"""
|
||||
|
||||
@Test
|
||||
fun `an edited frame carries the whole updated message`() {
|
||||
val ev = parse(
|
||||
editedFrame(
|
||||
data = """{"id":"m1","sessionId":"web","role":"user","content":"改过的内容",""" +
|
||||
""""mode":"chat","mood":"calm","createdAt":1753000000000}""",
|
||||
),
|
||||
) as WsEvent.MessageEdited
|
||||
|
||||
assertEquals("message_edited", ev.type)
|
||||
assertEquals("web", ev.sessionId)
|
||||
assertEquals("m1", ev.message?.id)
|
||||
assertEquals("user", ev.message?.role)
|
||||
assertEquals("改过的内容", ev.message?.content)
|
||||
assertEquals(1_753_000_000_000L, ev.message?.createdAt)
|
||||
// 消息类增量帧不走轮次 / 序号(与 message_persisted 一致)。
|
||||
assertNull(ev.turnId)
|
||||
assertNull(ev.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edited frame keeps the thinking the wire never carries as null, not as an error`() {
|
||||
// 契约 v0.59 §4.4:实时帧的 thinking 恒为空(有意剥除),解码层照旧接受缺键。
|
||||
val ev = parse(editedFrame(data = """{"id":"m1","sessionId":"web","role":"user","createdAt":1}"""))
|
||||
as WsEvent.MessageEdited
|
||||
|
||||
assertNull(ev.message?.thinking)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the message session is used when the shell omits its own`() {
|
||||
// 帧壳偶尔不带 sessionId:契约要求客户端仍能把它路由到正确的会话桶。
|
||||
val ev = parse(
|
||||
editedFrame(
|
||||
shellSession = "",
|
||||
data = """{"id":"m1","sessionId":"web:299bbf0b","role":"user","createdAt":1}""",
|
||||
),
|
||||
) as WsEvent.MessageEdited
|
||||
|
||||
assertEquals("web:299bbf0b", ev.sessionId)
|
||||
assertFalse(ev.isForSession("web"))
|
||||
assertTrue(ev.isForSession("web:299bbf0b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shell session wins over the message session`() {
|
||||
val ev = parse(
|
||||
editedFrame(
|
||||
shellSession = """"sessionId":"web",""",
|
||||
data = """{"id":"m1","sessionId":"web:other","role":"user","createdAt":1}""",
|
||||
),
|
||||
) as WsEvent.MessageEdited
|
||||
|
||||
assertEquals("web", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys in the payload are ignored`() {
|
||||
// 契约仍在演进(将来可能给编辑帧加字段),这一层不许把整帧挡回去。
|
||||
val ev = parse(
|
||||
editedFrame(
|
||||
data = """{"id":"m1","sessionId":"web","role":"user","createdAt":1,""" +
|
||||
""""clientMsgId":"local-1","futureKey":{"nested":true}}""",
|
||||
),
|
||||
) as WsEvent.MessageEdited
|
||||
|
||||
assertEquals("m1", ev.message?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edited frame without payload keeps a null message instead of inventing one`() {
|
||||
// 没有 data 就没有 id 也没有 content:上层据此整帧跳过(不编一条空消息出来)。
|
||||
val ev = parse("""{"type":"message_edited","sessionId":"web"}""") as WsEvent.MessageEdited
|
||||
|
||||
assertNull(ev.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted frame carries only the id`() {
|
||||
val ev = parse("""{"type":"message_deleted","sessionId":"web","data":{"id":"m1"}}""")
|
||||
as WsEvent.MessageDeleted
|
||||
|
||||
assertEquals("message_deleted", ev.type)
|
||||
assertEquals("m1", ev.id)
|
||||
assertEquals("web", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted frame without data degrades to a blank id, never to a mass delete`() {
|
||||
val ev = parse("""{"type":"message_deleted","sessionId":"web"}""") as WsEvent.MessageDeleted
|
||||
|
||||
assertEquals("", ev.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted frame with an explicit null id also degrades to blank`() {
|
||||
val ev = parse("""{"type":"message_deleted","sessionId":"web","data":{"id":null}}""")
|
||||
as WsEvent.MessageDeleted
|
||||
|
||||
assertEquals("", ev.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted frame without a shell session is accepted by every session`() {
|
||||
// 会话缺失 = 「无法判定」⇒ 放行;契约给的兜底是「id 全局唯一,逐桶搜索只会命中它自己那条」。
|
||||
val ev = parse("""{"type":"message_deleted","data":{"id":"m1"}}""") as WsEvent.MessageDeleted
|
||||
|
||||
assertNull(ev.sessionId)
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertTrue(ev.isForSession("web:other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a deleted frame for another session is dropped by the session gate`() {
|
||||
val ev = parse("""{"type":"message_deleted","sessionId":"web:other","data":{"id":"m1"}}""")
|
||||
|
||||
assertFalse(ev.isForSession("web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edited frame for another session is dropped by the session gate and blank ones pass`() {
|
||||
val other = parse(
|
||||
editedFrame(
|
||||
shellSession = """"sessionId":"web:other",""",
|
||||
data = """{"id":"m1","sessionId":"web:other","role":"user","createdAt":1}""",
|
||||
),
|
||||
)
|
||||
val unknown = parse(editedFrame(shellSession = "", data = """{"id":"m1","role":"user","createdAt":1}"""))
|
||||
|
||||
assertFalse(other.isForSession("web"))
|
||||
assertTrue(unknown.isForSession("web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank or missing payload never throws`() {
|
||||
// `data:null` / 缺 data:删除帧解成空 id(上层据此整帧跳过),不抛。
|
||||
val explicitNull = parse("""{"type":"message_deleted","sessionId":"web","data":null}""")
|
||||
as WsEvent.MessageDeleted
|
||||
assertEquals("", explicitNull.id)
|
||||
|
||||
// `data` 根本不是对象(坏帧):删除帧按未知帧忽略(返回 null),编辑帧仍成形但载荷为 null;
|
||||
// 两条都不会抛,消费方按「没有可用东西」跳过。
|
||||
assertNull(WsEvent.parse("""{"type":"message_deleted","sessionId":"web","data":"x"}"""))
|
||||
val garbage = parse("""{"type":"message_edited","sessionId":"web","data":"x"}""") as WsEvent.MessageEdited
|
||||
assertNull(garbage.message)
|
||||
// 编辑帧缺 data:事件仍成形(带帧壳的会话),载荷为 null。
|
||||
val noPayload = parse("""{"type":"message_edited","sessionId":"web"}""") as WsEvent.MessageEdited
|
||||
assertNull(noPayload.message)
|
||||
assertEquals("web", noPayload.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown frame type and a non json payload are ignored`() {
|
||||
assertNull(WsEvent.parse("""{"type":"message_removed","data":{"id":"m1"}}"""))
|
||||
assertNull(WsEvent.parse("not json at all"))
|
||||
assertNull(WsEvent.parse("""{"data":{"id":"m1"}}"""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 编辑 / 删除失败的**如实告知**(纯函数),以及失败该落在哪里。
|
||||
*
|
||||
* 两件事各自钉一组:
|
||||
*
|
||||
* 1. [messageActionFailure]:401 / 403 / 404 / 501 / 网络 / 未知,每一档**要用户做的事不同**,
|
||||
* 文案就不能混。尤其 401——`CyreneError.Unauthorized` 自带的兜底文案是「用户名或密码不正确」,
|
||||
* 直接透出去会让人去改密码,而正确的动作是重新登录;
|
||||
* 2. [ChatUiState.applyMessageActionFailure]:弹层还开着就留在弹层里(输入不丢、原地重试),
|
||||
* 弹层已经关了(用户在等结果时退出)就挂到页面横幅上——**任何一条都不得静默**。
|
||||
*/
|
||||
class MessageActionFailureTest {
|
||||
|
||||
private fun server(code: String, message: String = "", status: Int) =
|
||||
CyreneException(CyreneError.Server(code, message, status))
|
||||
|
||||
private fun state(action: MessageActionState?) = ChatUiState(messageAction = action)
|
||||
|
||||
// —— 文案 ——
|
||||
|
||||
@Test
|
||||
fun `an expired session asks the user to log in again, not to change the password`() {
|
||||
val expired = CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"))
|
||||
|
||||
val edit = messageActionFailure(expired, MessageActionKind.Edit)
|
||||
val delete = messageActionFailure(expired, MessageActionKind.Delete)
|
||||
|
||||
assertEquals("登录状态已失效,请重新登录后再编辑", edit)
|
||||
assertEquals("登录状态已失效,请重新登录后再删除", delete)
|
||||
assertTrue(edit, !edit.contains("密码"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a forbidden edit names the missing permission`() {
|
||||
val forbidden = server("forbidden", "forbidden", status = 403)
|
||||
|
||||
assertEquals("没有权限编辑这条消息", messageActionFailure(forbidden, MessageActionKind.Edit))
|
||||
assertEquals("没有权限删除这条消息", messageActionFailure(forbidden, MessageActionKind.Delete))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a message the server no longer has says so, because another client probably removed it`() {
|
||||
val gone = server("not_found", "message not found", status = 404)
|
||||
// 契约 §1 行 6a/6b:不存在回 404 not_found。状态码与错误码两条路都要认(代理可能改状态码)。
|
||||
val goneByCode = server("not_found", "", status = 400)
|
||||
|
||||
assertEquals(
|
||||
"这条消息在服务端已不存在,可能已被其它端删除",
|
||||
messageActionFailure(gone, MessageActionKind.Edit),
|
||||
)
|
||||
assertEquals(
|
||||
"这条消息在服务端已不存在,可能已被其它端删除",
|
||||
messageActionFailure(goneByCode, MessageActionKind.Delete),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server without a history store says the deployment cannot do it`() {
|
||||
val notImplemented = server("not_implemented", "history store unavailable", status = 501)
|
||||
|
||||
assertEquals(
|
||||
"服务端没有开启消息存储,无法编辑",
|
||||
messageActionFailure(notImplemented, MessageActionKind.Edit),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rejection keeps the server wording instead of inventing a number`() {
|
||||
val rejected = server("bad_request", "content must be at most 4000 runes", status = 400)
|
||||
|
||||
assertEquals("content must be at most 4000 runes", messageActionFailure(rejected, MessageActionKind.Edit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network failures name the step that failed and keep their own wording`() {
|
||||
val offline = CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork))
|
||||
val timeout = CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))
|
||||
|
||||
assertEquals("编辑失败,网络不可用,请检查连接", messageActionFailure(offline, MessageActionKind.Edit))
|
||||
assertEquals("删除失败,连接超时,服务器没有响应", messageActionFailure(timeout, MessageActionKind.Delete))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown throwable still produces an actionable sentence`() {
|
||||
assertEquals("编辑失败,请重试", messageActionFailure(IllegalStateException("boom"), MessageActionKind.Edit))
|
||||
assertEquals("删除失败,请重试", messageActionFailure(RuntimeException(), MessageActionKind.Delete))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no failure path ever yields an empty message`() {
|
||||
val failures = listOf(
|
||||
messageActionFailure(server("forbidden", "", status = 403), MessageActionKind.Edit),
|
||||
messageActionFailure(server("not_found", "", status = 404), MessageActionKind.Edit),
|
||||
messageActionFailure(server("not_implemented", "", status = 501), MessageActionKind.Edit),
|
||||
messageActionFailure(server("internal", "", status = 500), MessageActionKind.Delete),
|
||||
messageActionFailure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Unknown)), MessageActionKind.Delete),
|
||||
messageActionFailure(IllegalStateException(), MessageActionKind.Edit),
|
||||
)
|
||||
|
||||
assertTrue(failures.toString(), failures.all { it.isNotBlank() })
|
||||
}
|
||||
|
||||
// —— 失败落在哪里 ——
|
||||
|
||||
@Test
|
||||
fun `a failure lands inside the dialog while it is still open`() {
|
||||
val action = MessageActionState.Edit("m1", draft = "改过的内容", inFlight = true)
|
||||
|
||||
val after = state(action).applyMessageActionFailure(
|
||||
MessageActionKind.Edit,
|
||||
"m1",
|
||||
"登录状态已失效,请重新登录后再编辑",
|
||||
)
|
||||
|
||||
// 留在弹层里:用户就站在那儿,改完能直接重试,草稿一个字都不丢。
|
||||
assertEquals("登录状态已失效,请重新登录后再编辑", (after.messageAction as MessageActionState.Edit).error)
|
||||
assertEquals("改过的内容", (after.messageAction as MessageActionState.Edit).draft)
|
||||
assertEquals(false, after.messageAction?.inFlight)
|
||||
assertNull(after.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed delete keeps its own dialog too`() {
|
||||
val action = MessageActionState.Delete("m1", inFlight = true)
|
||||
|
||||
val after = state(action).applyMessageActionFailure(
|
||||
MessageActionKind.Delete,
|
||||
"m1",
|
||||
"这条消息在服务端已不存在,可能已被其它端删除",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"这条消息在服务端已不存在,可能已被其它端删除",
|
||||
(after.messageAction as MessageActionState.Delete).error,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure for another message or another kind goes to the page banner`() {
|
||||
// 用户在等结果时又去操作了别的消息(或关掉了弹层)⇒ 失败挂到页面顶部那行,**绝不静默**。
|
||||
val otherMessage = state(MessageActionState.Edit("m2", draft = "别的"))
|
||||
val otherKind = state(MessageActionState.Delete("m1"))
|
||||
|
||||
val afterOtherMessage = otherMessage.applyMessageActionFailure(MessageActionKind.Edit, "m1", "改失败了")
|
||||
val afterOtherKind = otherKind.applyMessageActionFailure(MessageActionKind.Edit, "m1", "改失败了")
|
||||
|
||||
assertEquals("改失败了", afterOtherMessage.error)
|
||||
assertEquals("别的", (afterOtherMessage.messageAction as MessageActionState.Edit).draft)
|
||||
assertEquals("改失败了", afterOtherKind.error)
|
||||
assertEquals("m1", afterOtherKind.messageAction?.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with no dialog open still shows up on the page`() {
|
||||
val after = ChatUiState().applyMessageActionFailure(MessageActionKind.Delete, "m1", "删不掉")
|
||||
|
||||
assertEquals("删不掉", after.error)
|
||||
assertNull(after.messageAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrelated failure leaves the state untouched`() {
|
||||
// 幂等的一面:失败的 messageId 与弹层无关且已有关闭前的横幅时,不做多余的改写。
|
||||
val before = state(MessageActionState.Edit("m1", draft = "x", inFlight = true))
|
||||
|
||||
val after = before.applyMessageActionFailure(MessageActionKind.Edit, "m2", "别的消息失败了")
|
||||
|
||||
assertSame(before.messageAction, after.messageAction)
|
||||
assertEquals("别的消息失败了", after.error)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user