merge: W19 — 单条消息的编辑与删除(D3)

This commit is contained in:
AskaEth committed 2026-10-04 21:28:22 +08:00
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)
}
}