feat(data,domain): 文档进对话数据层(v0.59 待发布形状 + 该不该走文件判定)

契约 v0.59 尚未发布(api-client.md 最新仍是 v0.58),形状依据
devdocs/02 回执五「契约形状修正」,并用后端已落库切片逐字段核对:
407c934(S1 存储端点)、a4789ec(S3-B 网关入口)、5b2b385(S3.5 多值化)。

新增:
- data/api/DocumentsApi.kt:POST /api/v1/documents(multipart 字段 file)、
  GET /api/v1/documents/{name}(Bearer owner|admin;@Streaming 取字节)
- data/api/dto/DocumentDto.kt:文档形状的唯一落点(v0.59 若不同只改这里)
- data/repository/DocumentRepository.kt:200 + deduped:true 当成功;url 是相对
  路径故不做图片那套绝对化校验;不做本地白名单/大小上限(服务端才是裁决者)
- domain/chat/DocumentDelivery.kt:该不该走文件的判定 + chat.documents 载荷构造
- domain/chat/DocumentStatus.kt / DocumentFailure.kt:状态与四个错误码的可分支映射

改动:
- ChatDto.ChatRequest 增 documents(默认空列表 ⇒ 旧请求逐字节不变,有字面量钉子)
- MessageDto.HistoryMessageDto 增 documents(缺键与空数组都解成空列表,覆盖
  后端「0 个」尚未定死的两种形状;WS message_persisted / history 回放共用该 DTO)
- ChatSendRouting.channelFor 增 hasDocuments(默认 false):带附件强制 REST,
  因为 WS 帧 4096 字节硬上限超限是直接断连,且文档一律走 REST

为什么不做 UI/VM:UI 与 ViewModel 文件由并行的分享入口模块占用,接线由主代理统一做。

测试:353 → 413(+60:DTO 12 / Repository 17 / Delivery 21 / Failure 10),
assembleDebug 与 testDebugUnitTest 均通过。
This commit is contained in:
AskaEth committed 2026-10-04 18:06:27 +08:00
1 parent 664fd8dcef
commit 095d6e781e
13 files changed
+1498 -1

No files matched your search

@@ -0,0 +1,230 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
import top.yeij.cyrene.domain.chat.DocumentStatus
/**
* 文档 DTO 的形状钉子。
*
* **形状来源:`Cyrene-For-Android/devdocs/02-前后端协同事项.md` 回执五,契约 v0.59 尚未发布**
* (写这些用例时 `Cyrene_v2/Docs/api-client.md` 最新仍是 v0.58,没有 documents 条目)。
* 字段集与取值另对齐后端已落库实现与它的测试:
* `407c934`(S1 存储端点 `documents.go` 的 `documentResponse`)、
* `5b2b385`(S3.5 文档多值化:`history.go` 的 `historyMessage.Documents` 带 `omitempty`、
* `history.DocumentRef` 的 `omitempty` 口径、`history_document_test.go` 钉住的元素字段集
* `chars,mime,name,size,status,truncated` 与它用的真实取值)。
* **没有 v0.59 抓包**:这里的 JSON 是按上述实现形状逐字节手写的(唯一确认过的真实取值
* 来自后端自己的测试夹具)。
*
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
*/
class DocumentDtoTest {
private val json = ApiClientFactory.json
private val name = "0123456789abcdef0123456789abcdef.txt"
private val sha256 = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60" +
"1a2b3c4d5e6f708192a3b4c5d6e7f809"
// —— 上传响应(201 首次 / 200 去重)——
private val uploadCreated = """{"data":{"name":"$name",""" +
""""url":"/api/v1/documents/$name","mime":"text/plain",""" +
""""size":12345,"sha256":"$sha256","deduped":false}}"""
private val uploadDeduped = """{"data":{"name":"$name",""" +
""""url":"/api/v1/documents/$name","mime":"text/plain",""" +
""""size":12345,"sha256":"$sha256","deduped":true}}"""
private fun upload(body: String) =
json.decodeFromString(ApiEnvelope.serializer(DocumentUploadResponse.serializer()), body)
@Test
fun `a first upload decodes name url mime size sha256 and deduped false`() {
val data = upload(uploadCreated).data!!
assertEquals(name, data.name)
// url 是**相对**路径:读侧要 Bearer,绝对化没有意义(与图片上传的绝对 URL 不同)。
assertEquals("/api/v1/documents/$name", data.url)
assertFalse("相对路径不能被当成绝对地址校验", data.url.startsWith("http"))
assertEquals("text/plain", data.mime)
assertEquals(12345L, data.size)
assertEquals(sha256, data.sha256)
assertFalse(data.deduped)
}
@Test
fun `a deduped upload is a successful envelope with the same name, not an error`() {
val envelope = upload(uploadDeduped)
// 200 + deduped:true 走的是**成功信封**(data 在、error 不在)——客户端必须当成功。
assertNull(envelope.error)
val data = envelope.data!!
assertEquals(name, data.name)
assertTrue(data.deduped)
}
@Test
fun `an unknown extra key in the upload payload does not break decoding`() {
val body = """{"data":{"name":"$name","url":"/api/v1/documents/$name",""" +
""""mime":"text/plain","size":1,"sha256":"$sha256","deduped":false,"gcAt":123}}"""
assertEquals(name, upload(body).data!!.name)
}
// —— 历史 / 响应消息里的 documents 数组 ——
private fun message(body: String) =
json.decodeFromString(ApiEnvelope.serializer(HistoryMessageDto.serializer()), body).data!!
/** 后端 `history_document_test.go` 用的那一组取值:`{name,mime,size,chars,truncated,status}`。 */
private fun withDocuments(documents: String) = """{"data":{"id":"m1","sessionId":"web",""" +
""""role":"user","content":"总结这份报告","documents":$documents,"createdAt":1791088571785}}"""
@Test
fun `a message with one document decodes the reference and its statistics`() {
val decoded = message(
withDocuments(
"""[{"name":"$name","mime":"text/plain","size":12345,"chars":8000,""" +
""""truncated":true,"status":"extracted"}]""",
),
)
val doc = decoded.documents.single()
assertEquals(name, doc.name)
assertEquals("text/plain", doc.mime)
assertEquals(12345L, doc.size)
assertEquals(8000, doc.chars)
assertTrue(doc.truncated)
assertEquals(DocumentStatus.EXTRACTED, doc.status)
assertEquals("总结这份报告", decoded.content)
}
@Test
fun `several documents keep the server order`() {
val second = "fedcba9876543210fedcba9876543210.md"
val docs = message(
withDocuments(
"""[{"name":"$name","mime":"text/plain","size":12345,"chars":8000,"status":"extracted"},""" +
"""{"name":"$second","mime":"text/plain","size":42,"status":"unreadable"}]""",
),
).documents
// 一回合多个附件**原样回传**(S3.5 修的就是「只留第一个」)。
assertEquals(listOf(name, second), docs.map { it.name })
assertEquals(DocumentStatus.UNREADABLE, docs[1].status)
assertTrue(DocumentStatus.isUnreadable(docs[1].status))
assertFalse("截断与读不到是两件事", DocumentStatus.isUnreadable(docs[0].status))
assertNull("`chars` 缺键 → null(服务端 omitempty),不是 0", docs[1].chars)
assertFalse("`truncated` 缺键 → false", docs[1].truncated)
}
@Test
fun `a missing documents key is an empty list`() {
// 后端 `json:"documents,omitempty"`:没有附件的消息**省略该字段**(空列与带附件必须可区分)。
val decoded = message(
"""{"data":{"id":"m1","sessionId":"web","role":"user",""" +
""""content":"没有附件","createdAt":1791088571785}}""",
)
assertTrue(decoded.documents.isEmpty())
}
@Test
fun `an empty documents array is an empty list too`() {
// 回执五原文:「0 个时期望**不出现或空数组**,S3.5 回报里会定死一种」⇒ 两种都要吃。
val decoded = message(withDocuments("[]"))
assertTrue(decoded.documents.isEmpty())
}
@Test
fun `an unknown status is preserved verbatim`() {
// status 是开放集合:不丢弃、不报错、不占位符(与 mode / platform / mood 同一口径)。
val status = "quarantined-v2"
val doc = message(withDocuments("""[{"name":"$name","status":"$status"}]""")).documents.single()
assertEquals(status, doc.status)
assertFalse(DocumentStatus.isUnreadable(status))
}
@Test
fun `a reference without the optional statistics decodes with the wire defaults`() {
val doc = message(withDocuments("""[{"name":"$name","mime":"text/plain","size":7}]"""))
.documents.single()
assertEquals(name, doc.name)
assertEquals(7L, doc.size)
assertNull(doc.chars)
assertFalse(doc.truncated)
assertEquals("", doc.status)
}
// —— chat 请求:带附件与不带附件 ——
@Test
fun `a request without documents is byte identical to the old request`() {
val encoded = json.encodeToString(
ChatRequest.serializer(),
ChatRequest(
sessionId = "web",
text = "嗨",
deviceId = "voiceassist:cyrene:v1",
stream = true,
clientMsgId = "local-1",
),
)
// 字面量对比:默认空列表 ⇒ `documents` **整键省略**(`encodeDefaults = false`),
// 键序与加入该字段之前逐字节相同。服务端 `documents,omitempty` 也是零值省略。
assertEquals(
"""{"sessionId":"web","text":"嗨","deviceId":"voiceassist:cyrene:v1",""" +
""""stream":true,"clientMsgId":"local-1"}""",
encoded,
)
}
@Test
fun `a request with documents carries only the server side storage name`() {
val encoded = json.encodeToString(
ChatRequest.serializer(),
ChatRequest(
sessionId = "web",
text = "看看这个",
stream = true,
documents = listOf(DocumentRefDto(name = name)),
),
)
val documents = json.parseToJsonElement(encoded).jsonObject["documents"]!!.jsonArray
// 上行元素**只有 name**:不收 URL、不收 mime(服务端不采信并覆盖,见回执五 §一 S3-B)。
assertEquals(1, documents.size)
val element = documents[0].jsonObject
assertEquals(setOf("name"), element.keys)
assertEquals(name, element["name"]!!.jsonPrimitive.content)
}
@Test
fun `the read leg element keeps the statistics the request leg never sends`() {
// 上行/下行共用同一个元素类型:写出去时由 ChatRequest 决定只发 name(上一用例),
// 读回来时统计字段照常解出(前面的用例)。这里把「读侧元素该长什么样」也钉一下。
val encoded = json.encodeToString(
DocumentRefDto.serializer(),
DocumentRefDto(name = name, mime = "text/plain", size = 1, status = DocumentStatus.LISTED),
)
assertEquals("""{"name":"$name","mime":"text/plain","size":1,"status":"listed"}""", encoded)
}
}
@@ -0,0 +1,324 @@
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.DocumentsApi
import top.yeij.cyrene.domain.chat.DocumentFailure
import top.yeij.cyrene.domain.chat.toDocumentFailure
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 文档端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端(与
* [UploadRepositoryTest] / [ChatRepositoryTest] 同一手法,不引新依赖)。
*
* **形状来源:`02-前后端协同事项.md` 回执五,契约 v0.59 尚未发布**
* (`api-client.md` 最新仍是 v0.58,没有 documents 条目)。夹具逐字段对齐后端实现:
* `documents.go` 的 `documentResponse`(`{name,url,mime,size,sha256,deduped}`,`url` 是
* **相对**路径、`201` 首次 / `200` 去重)与它的错误码常量、
* `chat_documents.go` 的 `too_many_documents`、`maxChatDocuments = 5`。
* **没有 v0.59 抓包**:响应体是按实现形状手写的。
*
* 要证明的四件事:
* 1. 请求**真的是 multipart**、部件名真的是 `file`、**文件名(含扩展名)原样发出**、
* 字节一个不改(服务端把白名单扩展名与字节嗅探合取,名字是裁决的一半);
* 2. **`200` + `deduped:true` 是成功**,不是错误;
* 3. 四个错误码按 `code` 分成可辨的四类(映射到 [DocumentFailure]);
* 4. **两个端点都自带 Bearer**(文档读侧要 `owner|admin`,而 token 是挂在 OkHttpClient 上的,
* 不是挂在某个接口上的——这里把「下载也带上了 Authorization」实测钉住)。
*/
class DocumentRepositoryTest {
private val backend = FakeDocumentBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = DocumentRepository(
ApiClientFactory.create(
baseUrl = backend.baseUrl,
service = DocumentsApi::class.java,
tokenProvider = { TOKEN },
),
)
/** 这次调用**必须**失败,并把它收敛成可分支的失败类型。 */
private fun Result<*>.why(): DocumentFailure =
(exceptionOrNull() ?: error("这一次调用本该失败")).toDocumentFailure()
/** 一段带扩展名的文本:足够让「字节原样发出」与「文件名原样发出」两件事都可断言。 */
private val markdown = "# 报告\n昔涟的周报\n".toByteArray(Charsets.UTF_8)
@Test
fun `a first upload returns the server side storage name and the statistics`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
val uploaded = repository().upload(markdown, "报告.md").getOrThrow()
assertEquals(backend.name, uploaded.name)
assertEquals("/api/v1/documents/${backend.name}", uploaded.url)
assertEquals("text/plain", uploaded.mime)
assertEquals(markdown.size.toLong(), uploaded.size)
assertEquals(backend.sha256, uploaded.sha256)
assertFalse(uploaded.deduped)
}
@Test
fun `a deduped upload is a success with the same name, not an error`() = runBlocking {
// 同一份字节重复上传:服务端回 200 + 同一个 name + deduped:true。**必须当成功处理**。
backend.replyUpload(200, backend.uploadBody(deduped = true))
val uploaded = repository().upload(markdown, "报告.md").getOrThrow()
assertEquals(backend.name, uploaded.name)
assertTrue(uploaded.deduped)
}
@Test
fun `the request really is multipart with the file part name and the real extension`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "report.md").getOrThrow()
val request = backend.requests.single()
assertEquals("POST", request.method)
assertEquals("/api/v1/documents", request.path)
assertTrue(request.contentType, request.contentType.startsWith("multipart/form-data"))
// 部件名 / 文件名都要在:Go 只把带非空 filename 的部件当文件,而扩展名参与白名单判定。
assertTrue(request.body, request.body.contains("""name="file""""))
assertTrue(request.body, request.body.contains("""filename="report.md""""))
assertTrue(request.body, request.body.contains("Content-Type: text/plain"))
}
@Test
fun `a name without an extension still goes out as a file part`() = runBlocking {
// 空名字会让 Go 的 FormFile 直接找不到文件(返回 400「需要 multipart 字段 file」),
// 所以仓库必须兜一个带扩展名的名字——但扩展名的裁决权仍在服务端。
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "").getOrThrow()
assertTrue(backend.requests.single().body.contains("""filename="document.txt""""))
}
@Test
fun `the bytes leave untouched, utf8 included`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "report.md").getOrThrow()
// 记录侧按 ISO-8859-1 解码(字节 ↔ 字符一一对应),所以期望值也要同一解码,
// 这样断言的才是**线上真实字节**,而不是「解码后看起来像」。
val expected = String(markdown, Charsets.ISO_8859_1)
assertTrue(backend.requests.single().body, backend.requests.single().body.contains(expected))
}
@Test
fun `415 unsupported_media_type becomes the type branch`() = runBlocking {
backend.replyUpload(415, """{"error":{"code":"unsupported_media_type","message":"仅接受文本类文档"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.UnsupportedType("仅接受文本类文档"), failure)
}
@Test
fun `413 document_too_large becomes the size branch`() = runBlocking {
backend.replyUpload(
413,
"""{"error":{"code":"document_too_large","message":"文档过大:上限 20.0 MiB,本次 30.0 MiB"}}""",
)
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(
DocumentFailure.TooLarge("文档过大:上限 20.0 MiB,本次 30.0 MiB"),
failure,
)
}
@Test
fun `400 too_many_documents becomes the count branch`() = runBlocking {
backend.replyUpload(400, """{"error":{"code":"too_many_documents","message":"单回合附件最多 5 条,本次 6 条"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.TooMany("单回合附件最多 5 条,本次 6 条"), failure)
}
@Test
fun `404 document_not_found becomes the missing branch`() = runBlocking {
backend.replyUpload(404, """{"error":{"code":"document_not_found","message":"文档不存在"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `401 stays a forbidden failure because the session itself is no longer valid`() = runBlocking {
backend.replyUpload(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.Forbidden, failure)
}
@Test
fun `a successful upload without a usable name is a parse failure`() = runBlocking {
// 201 却没有存储名 = 上传了但引用不上:不能退回本地文件名(服务端已经丢掉了它)。
backend.replyUpload(201, """{"data":{"name":"","url":"","mime":"text/plain","size":1,"sha256":"","deduped":false}}""")
val error = repository().upload(markdown, "报告.md").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `a 201 without data is a parse failure`() = runBlocking {
backend.replyUpload(201, "{}")
val error = repository().upload(markdown, "报告.md").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `an unreachable server is a local failure`() = runBlocking {
backend.close()
val error = repository().upload(markdown, "报告.md").exceptionOrNull() as CyreneException
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
}
// —— 取字节 ——
@Test
fun `download returns the raw bytes and carries the bearer token`() = runBlocking {
backend.replyDownload(200, markdown)
val bytes = repository().download(backend.name).getOrThrow()
assertEquals(markdown.toList(), bytes.toList())
val request = backend.requests.single()
assertEquals("GET", request.method)
assertEquals("/api/v1/documents/${backend.name}", request.path)
// 读侧要 Bearer owner|admin(与图片读侧刻意不同)——拦截器是按 OkHttpClient 挂的,
// 所以这条请求自带令牌,不需要在接口上单独加头。
assertEquals("Bearer $TOKEN", request.authorization)
}
@Test
fun `an upload also carries the bearer token`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "报告.md").getOrThrow()
assertEquals("Bearer $TOKEN", backend.requests.single().authorization)
}
@Test
fun `a missing document on download becomes the missing branch`() = runBlocking {
backend.replyDownload(404, """{"error":{"code":"document_not_found","message":"文档不存在"}}""")
val failure = repository().download(backend.name).why()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `an empty download body is a failure, not an empty document`() = runBlocking {
backend.replyDownload(500, "")
val failure = repository().download(backend.name).why()
assertTrue("实际 $failure", failure is DocumentFailure.Other)
}
/** 记录每次请求的原始字节与头,按 `replyUpload` / `replyDownload` 回话的假后端。 */
private inner class FakeDocumentBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
// 前缀匹配:上传(POST /api/v1/documents)与取值(GET /api/v1/documents/<name>)共用。
createContext("/api/v1/documents", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
/** 服务端生成的存储名:32 位小写 hex + 白名单扩展名。 */
val name = "0123456789abcdef0123456789abcdef.md"
val sha256 = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f601a2b3c4d5e6f708192a3b4c5d6e7f809"
val requests = CopyOnWriteArrayList<RecordedRequest>()
@Volatile
private var status: Int = 201
@Volatile
private var body: ByteArray = ByteArray(0)
fun replyUpload(status: Int, body: String) = reply(status, body.toByteArray(Charsets.UTF_8))
fun replyDownload(status: Int, bytes: ByteArray) = reply(status, bytes)
/** 错误回执走的是 `{error}` 信封(JSON 文本)。 */
fun replyDownload(status: Int, body: String) = reply(status, body.toByteArray(Charsets.UTF_8))
/** 线上的成功形状(`documentResponse`):`url` 是**相对**路径,`size` 是服务端统计值。 */
fun uploadBody(deduped: Boolean): String =
"""{"data":{"name":"$name","url":"/api/v1/documents/$name","mime":"text/plain",""" +
""""size":${markdown.size},"sha256":"$sha256","deduped":$deduped}}"""
private fun reply(status: Int, bytes: ByteArray) {
this.status = status
this.body = bytes
}
private fun handle(exchange: HttpExchange) {
// ISO-8859-1:字节 ↔ 字符一一对应,断言里比对的就是线上真实字节。
val raw = exchange.requestBody.readBytes()
requests += RecordedRequest(
method = exchange.requestMethod,
path = exchange.requestURI.path,
contentType = exchange.requestHeaders.getFirst("Content-Type").orEmpty(),
authorization = exchange.requestHeaders.getFirst("Authorization").orEmpty(),
body = raw.toString(Charsets.ISO_8859_1),
)
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, body.size.toLong())
exchange.responseBody.use { it.write(body) }
}
override fun close() {
runCatching { server.stop(0) }
}
}
private data class RecordedRequest(
val method: String,
val path: String,
val contentType: String,
val authorization: String,
val body: String,
)
private companion object {
const val TOKEN = "test-token"
}
}
@@ -0,0 +1,239 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.DocumentRefDto
/**
* 「该不该走文件」的判定矩阵与 `chat.documents` 载荷构造(纯函数)。
*
* **形状来源**:`02-前后端协同事项.md` **回执五**(契约 v0.59 尚未发布)——
* 「≤5 条」「只收服务端存储名」「文档一律走 REST」「WS 上行帧 4096 字节硬上限仍在」;
* 数值另对齐后端实现与测试:`chat_documents.go` 的 `maxChatDocuments = 5`、
* `wsReadLimit = 4096`(契约 v0.56 明确记载)。
*
* **本端自定的界线在测试里标明了**:[DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT] 是客户端
* 「正文量级不像一条消息」的参照(借 `PATCH /api/v1/messages/{id}` 的 4000 rune 上限;
* **`/chat` 本身没有正文长度上限**),所以这里同时钉住「超 WS 帧限 ⇒ 只是走 REST,**不是**
* 转文件」——那条界线属 [ChatSendRouting],本对象不另立阈值。
*/
class DocumentDeliveryTest {
private fun file(name: String = "note.txt", size: Int = 16) =
DocumentFile(fileName = name, bytes = ByteArray(size))
private fun files(count: Int) = List(count) { file(name = "note-$it.txt") }
// —— 正文界线 ——
@Test
fun `empty content with no attachments is an inline send`() {
// 纯空消息:调用方在更外层已经拦掉(text+image 至少一项),这里不该把它变成附件或报错。
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan("", emptyList()))
}
@Test
fun `a short message stays inline`() {
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan("你好", emptyList()))
}
@Test
fun `the rune threshold is inclusive - exactly the limit still goes inline`() {
val atLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
assertEquals(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT, DocumentDelivery.runeCount(atLimit))
assertFalse(DocumentDelivery.textNeedsFile(atLimit))
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan(atLimit, emptyList()))
}
@Test
fun `one rune over the limit turns the text into a document`() {
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertTrue(DocumentDelivery.textNeedsFile(overLimit))
assertEquals(
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = true),
DocumentDelivery.plan(overLimit, emptyList()),
)
}
@Test
fun `rune counting follows code points so an emoji counts as one`() {
// UTF-16 里 emoji 是 2 个 char,Go 的 rune 与 Unicode 码点都算 1 —— 用 `length` 会提前一倍转文件。
val emoji = "🎐"
assertEquals(2, emoji.length)
assertEquals(1, DocumentDelivery.runeCount(emoji))
val overLimit = emoji.repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertEquals(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1, DocumentDelivery.runeCount(overLimit))
assertTrue(DocumentDelivery.textNeedsFile(overLimit))
}
// —— 「超 WS 帧限 = 换通道」与「超 rune 界线 = 换形态」是两件事 ——
@Test
fun `a text over the frame limit but under the rune threshold goes REST, not to a file`() {
// 4000 个汉字 = 12000 字节,远超 WS 文字的 3584 字节:必须走 REST(否则服务端**直接断连**),
// 但按 rune 它仍是一条消息 —— 转文件会平白改掉一条已验证过的路径。
val text = "昔".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
assertTrue(DocumentDelivery.textNeedsRest(text))
assertFalse(DocumentDelivery.textNeedsFile(text))
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan(text, emptyList()))
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor(text, hasImage = false, hasDocuments = false),
)
}
@Test
fun `the REST boundary is the frame limit, inclusive on the websocket side`() {
val atLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val overLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertFalse(DocumentDelivery.textNeedsRest(atLimit))
assertTrue(DocumentDelivery.textNeedsRest(overLimit))
}
@Test
fun `attachments always go over REST even when the text would fit the websocket`() {
// 文档一律走 REST:上传换引用这条链路整个压在同一条通道上。
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor("嗨", hasImage = false, hasDocuments = true),
)
// 默认参数让既有调用方(无附件)行为逐字不变。
assertEquals(
SendChannel.Ws,
ChatSendRouting.channelFor("嗨", hasImage = false),
)
assertEquals(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN, 5)
}
// —— 附件数 ——
@Test
fun `one attachment is enough to switch to the document channel`() {
val one = files(1)
assertEquals(
DocumentPlan.AsDocuments(files = one, textAsDocument = false),
DocumentDelivery.plan("看看这个", one),
)
}
@Test
fun `exactly the maximum number of attachments is accepted`() {
val five = files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN)
assertEquals(5, five.size)
assertEquals(
DocumentPlan.AsDocuments(files = five, textAsDocument = false),
DocumentDelivery.plan("", five),
)
}
@Test
fun `one attachment over the maximum is explicitly rejected, never trimmed`() {
// 静默只发前 5 个 = 用户以为 6 份都传上去了。必须让调用方给出提示。
val six = files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.plan("看看这些", six),
)
}
@Test
fun `the attachment limit is checked before anything else`() {
// 超限 + 长正文:回复必须是「附件太多」,不能落进「把正文转文件」那条分支。
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.plan(overLimit, files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1)),
)
}
@Test
fun `a long text plus attachments asks for both`() {
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = two, textAsDocument = true),
DocumentDelivery.plan(overLimit, two),
)
}
@Test
fun `a text at the limit plus attachments keeps the text inline in the request`() {
val atLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = two, textAsDocument = false),
DocumentDelivery.plan(atLimit, two),
)
}
// —— 载荷构造 ——
@Test
fun `the payload carries only the storage name`() {
val names = listOf("0123456789abcdef0123456789abcdef.txt")
val payload = DocumentDelivery.payload(names)!!
assertEquals(1, payload.size)
assertEquals("0123456789abcdef0123456789abcdef.txt", payload.single().name)
// 上行只发 name:其它字段留默认值(`encodeDefaults = false` 于是整键省略)。
assertEquals("", payload.single().mime)
assertEquals(0L, payload.single().size)
}
@Test
fun `the payload accepts exactly five names`() {
val names = List(5) { "0123456789abcdef0123456789abcdef$it.txt" }
assertEquals(5, DocumentDelivery.payload(names)!!.size)
}
@Test
fun `the payload refuses six names instead of trimming`() {
val names = List(6) { "0123456789abcdef0123456789abcdef$it.txt" }
assertNull(DocumentDelivery.payload(names))
}
@Test
fun `the payload refuses a blank name instead of dropping it`() {
// 丢掉空名字 = 少发一份附件且双方都不知道;服务端对非法/不存在的名字回 404,客户端也不能静默裁。
assertNull(DocumentDelivery.payload(listOf("0123456789abcdef0123456789abcdef.txt", " ")))
}
@Test
fun `an empty payload list stays empty`() {
assertEquals(emptyList<DocumentRefDto>(), DocumentDelivery.payload(emptyList()))
}
// —— 长正文落成 .txt ——
@Test
fun `a text becomes a utf8 txt file the server whitelist accepts`() {
val file = DocumentDelivery.textAsFile("昔涟")
assertEquals("message.txt", file.fileName)
assertTrue("服务端按扩展名判白名单,必须带 .txt", file.fileName.endsWith(".txt"))
assertEquals("昔涟".toByteArray(Charsets.UTF_8).size.toLong(), file.sizeBytes)
assertEquals("昔涟", String(file.bytes, Charsets.UTF_8))
}
@Test
fun `the txt file name can be overridden for the share intake`() {
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName)
}
}
@@ -0,0 +1,107 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 文档错误码 → 可分支失败类型的映射(纯函数)。
*
* **来源**:`02-前后端协同事项.md` **回执五**(契约 v0.59 尚未发布)§一 S1 的四个错误码表,
* 另核过后端实现里的常量(`documents.go` 的 `documentUnsupportedCode` /
* `documentTooLargeCode` / `documentNotFoundCode`、`chat_documents.go` 的
* `tooManyDocumentsCode` / `documentsUnavailableCode`)。文案一律用**服务端产出的原文**,
* 客户端不自己编数字。
*/
class DocumentFailureTest {
private fun server(code: String, httpStatus: Int, message: String = "服务端的话") =
CyreneException(CyreneError.Server(code = code, message = message, httpStatus = httpStatus))
@Test
fun `unsupported_media_type maps to a type problem the user can only fix by changing the file`() {
val failure = server("unsupported_media_type", 415, "扩展名不在白名单内").toDocumentFailure()
assertEquals(DocumentFailure.UnsupportedType("扩展名不在白名单内"), failure)
}
@Test
fun `document_too_large maps to the size branch and keeps the server numbers`() {
val failure = server("document_too_large", 413, "文档过大:上限 20.0 MiB").toDocumentFailure()
assertEquals(DocumentFailure.TooLarge("文档过大:上限 20.0 MiB"), failure)
}
@Test
fun `too_many_documents maps to the count branch`() {
val failure = server("too_many_documents", 400, "单回合附件最多 5 条,本次 6 条").toDocumentFailure()
assertEquals(DocumentFailure.TooMany("单回合附件最多 5 条,本次 6 条"), failure)
}
@Test
fun `document_not_found maps to the missing branch`() {
val failure = server("document_not_found", 404, "文档不存在").toDocumentFailure()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `document_unreadable is its own branch because it is not an upload failure`() {
// 只在显式索取正文时出现(如 document-read):与「上传被拒」是不同的用户动作。
val failure = server("document_unreadable", 400, "正文读不出来").toDocumentFailure()
assertEquals(DocumentFailure.Unreadable("正文读不出来"), failure)
}
@Test
fun `401 and 403 both mean the session cannot do this`() {
assertEquals(
DocumentFailure.Forbidden,
CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")).toDocumentFailure(),
)
assertEquals(
DocumentFailure.Forbidden,
server("forbidden", 403).toDocumentFailure(),
)
}
@Test
fun `an unmapped server code keeps its message instead of being swallowed`() {
// 后端确有这个码:“文档解析器未接线”(500)。不认识也要原样带出来,绝不静默当成成功。
val failure = server("chat_documents_unavailable", 500, "文档解析器未接线").toDocumentFailure()
assertEquals(DocumentFailure.Other("文档解析器未接线"), failure)
}
@Test
fun `a local network failure falls back to the client message`() {
val failure = CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)).toDocumentFailure()
assertTrue("实际 $failure", failure is DocumentFailure.Other)
assertEquals("连接超时,服务器没有响应", failure.message)
}
@Test
fun `a throwable that is not a cyrene error still gets a message`() {
val failure = IllegalStateException("boom").toDocumentFailure()
assertEquals(DocumentFailure.Other("文档操作失败,请稍后重试"), failure)
}
@Test
fun `the five document codes stay five distinct branches`() {
// 这条是给「日后有人图省事把它们合成一个」留的反向钉子。
val branches = listOf(
server("unsupported_media_type", 415).toDocumentFailure(),
server("document_too_large", 413).toDocumentFailure(),
server("too_many_documents", 400).toDocumentFailure(),
server("document_not_found", 404).toDocumentFailure(),
server("document_unreadable", 400).toDocumentFailure(),
)
assertEquals(5, branches.toSet().size)
}
}