merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状)
This commit is contained in:
commit
a80dac7a7e
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user