merge: W3 — B1 /upload(大图走上传,历史只留 URL)

This commit is contained in:
AskaEth committed 2026-10-04 17:44:23 +08:00
commit 664fd8dcef
11 files changed
+869 -6

No files matched your search

@@ -0,0 +1,143 @@
package top.yeij.cyrene.data.api.dto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
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.ApiEnvelope
import top.yeij.cyrene.data.api.ApiErrorMapper
/**
* 契约钉死测试:`POST /api/v1/upload` 的**成功载荷**用的是服务端
* `internal/services/gateway/upload.go` 的 `uploadResponse` 结构体形状(字段名逐字:
* `url` / `size` / `mime` / `name`),**错误载荷**用的是 2026-10-04 对着在线后端
* (`http://127.0.0.1:10942`)**实测抓下来**的原文——只发错误请求(缺部件 / 非图片 / 超限),
* 服务端一律在落盘之前拒绝,所以这些抓包**没有在服务端留下任何文件**。
*
* 解码器用 [ApiClientFactory.json]——与生产同一份 `Json`,这里过不了的形状线上也过不了。
*/
class UploadDtoRealPayloadTest {
private val json = ApiClientFactory.json
/** 成功载荷:32 位小写 hex 名 + 白名单扩展名 + 同源绝对 URL,全是服务端生成的。 */
private val realSuccess = """
{"data":{"url":"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg","size":184320,"mime":"image/jpeg","name":"9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"}}
""".trimIndent()
private fun decode(body: String): UploadResponse =
json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), body).data!!
@Test
fun `the real success payload decodes into a directly usable url`() {
val data = decode(realSuccess)
assertEquals(
"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg",
data.url,
)
assertEquals(184320L, data.size)
assertEquals("image/jpeg", data.mime)
assertEquals("9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg", data.name)
}
@Test
fun `the url is absolute same-origin because chat image only accepts http s urls`() {
val url = decode(realSuccess).url
// 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 载荷 —— 所以这个形状是硬要求。
assertTrue(url, url.startsWith("http://") || url.startsWith("https://"))
assertTrue(url, url.contains("/api/v1/files/"))
}
@Test
fun `the name is 32 lowercase hex plus a whitelisted raster extension`() {
val name = decode(realSuccess).name
val stem = name.substringBeforeLast('.')
assertEquals(32, stem.length)
assertEquals(stem, stem.lowercase())
assertTrue(stem, stem.all { it in "0123456789abcdef" })
// 白名单只有光栅:SVG / HTML 会被拒(`uploadExtForMIME`)。
assertTrue(name, name.endsWith(".jpg"))
}
// —— 以下四条是**在线抓包原文**(逐字照抄) ——
@Test
fun `the captured 415 body maps to unsupported_media_type`() {
val body = """{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}"""
val error = ApiErrorMapper.fromHttp(415, body)
assertEquals(CyreneError.Server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415), error)
// 客户端按 code 分支(改文案不改判定),所以这两个字符串必须钉住。
assertEquals("unsupported_media_type", (error as CyreneError.Server).code)
}
@Test
fun `the captured 413 body maps to payload_too_large`() {
val body = """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}"""
val error = ApiErrorMapper.fromHttp(413, body) as CyreneError.Server
assertEquals("payload_too_large", error.code)
assertEquals(413, error.httpStatus)
}
@Test
fun `a wrong part name is a 400 that names the field`() {
// 实测:部件名不是 `file`(例如 `image`)→ 这条。它是「字段名写错」的唯一征兆。
val body = """{"error":{"code":"bad_request","message":"file is required"}}"""
val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server
assertEquals("bad_request", error.code)
assertEquals("file is required", error.message)
}
@Test
fun `a body that is not multipart at all is a 400 with a different message`() {
val body = """{"error":{"code":"bad_request","message":"invalid multipart body"}}"""
val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server
assertEquals("bad_request", error.code)
// 与「部件名错了」同码不同文案:排障时靠 message 区分,靠 code 分支。
assertEquals("invalid multipart body", error.message)
}
@Test
fun `the captured 401 is unauthorized and not a server rejection`() {
val error = ApiErrorMapper.fromHttp(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
assertEquals(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"), error)
}
// —— 形状的松紧 ——
@Test
fun `a success envelope without data is not an upload`() {
assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), "{}").data)
assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), """{"data":null}""").data)
}
@Test
fun `unknown extra keys are ignored so a future field cannot break decoding`() {
val data = decode("""{"data":{"url":"http://h/api/v1/files/aa.jpg","size":1,"mime":"image/jpeg","name":"aa.jpg","sha256":"deadbeef"}}""")
assertEquals("http://h/api/v1/files/aa.jpg", data.url)
}
@Test
fun `explicit nulls fall back to the defaults instead of failing the decode`() {
val data = decode("""{"data":{"url":null,"size":null,"mime":null,"name":null}}""")
assertEquals("", data.url)
assertEquals(0L, data.size)
assertEquals("", data.mime)
assertEquals("", data.name)
}
}
@@ -0,0 +1,227 @@
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.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.UploadApi
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 上传端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端(不引新依赖,
* 与 [ChatRepositoryTest] 同一手法)。
*
* 这里要证明的不是「能解析一个 JSON」,而是三件线上真会出事的事:
* 1. 请求**真的是 multipart**、部件名真的是 `file`、载荷字节**一个不改**地发出去
* (服务端 `r.FormFile("file")` 认不到就回 `400 file is required`);
* 2. `413 payload_too_large` / `415 unsupported_media_type` 按 `code` 分成可辨的两类;
* 3. 服务端给的 url **不是绝对 http(s) 就判失败**——`chat.image` 会把裸路径当成 base64,
* 照发等于把这一轮扔进解析错误里。
*/
class UploadRepositoryTest {
private val backend = FakeUploadBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = UploadRepository(
ApiClientFactory.create(baseUrl = backend.baseUrl, service = UploadApi::class.java),
)
/** 一段带 JPEG 魔数的假图:足够让「字节原样发出」这件事可断言。 */
private val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) +
"B1-UPLOAD-PAYLOAD".toByteArray()
@Test
fun `a successful upload returns the server built url`() = runBlocking {
backend.reply(201, backend.successBody())
val uploaded = repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
assertEquals(backend.url, uploaded.url)
// size / mime / name 是**服务端**的数,客户端只传递、不自己算一遍。
assertEquals(jpeg.size.toLong(), uploaded.size)
assertEquals("image/jpeg", uploaded.mime)
assertEquals(backend.name, uploaded.name)
}
@Test
fun `the request really is multipart with the file part name the server looks for`() = runBlocking {
backend.reply(201, backend.successBody())
repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
val request = backend.requests.single()
assertEquals("POST", request.method)
assertEquals("/api/v1/upload", request.path)
assertTrue(request.contentType, request.contentType.startsWith("multipart/form-data"))
// 部件名 / 文件名都要在:Go 只把带 filename 的部件当文件,字段名不对则 FormFile 找不到。
assertTrue(request.body, request.body.contains("""name="file""""))
assertTrue(request.body, request.body.contains("""filename="upload.jpg""""))
assertTrue(request.body, request.body.contains("Content-Type: image/jpeg"))
}
@Test
fun `the bytes leave untouched, jpeg magic included`() = runBlocking {
backend.reply(201, backend.successBody())
repository().uploadImage(jpeg, "image/jpeg").getOrThrow()
// ISO-8859-1 一一对应字节:这里的断言就是「发出去的就是编出来的那些字节」。
val body = backend.requests.single().body
assertTrue(body, body.contains("\u00FF\u00D8\u00FF\u00E0"))
assertTrue(body, body.contains("B1-UPLOAD-PAYLOAD"))
}
@Test
fun `a png is sent as a png part`() = runBlocking {
backend.reply(201, backend.successBody(mime = "image/png"))
repository().uploadImage(byteArrayOf(0x89.toByte(), 0x50), "image/png").getOrThrow()
val body = backend.requests.single().body
assertTrue(body, body.contains("Content-Type: image/png"))
assertTrue(body, body.contains("""filename="upload.png""""))
}
@Test
fun `413 payload_too_large is surfaced with its code`() = runBlocking {
backend.reply(413, """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(
CyreneError.Server("payload_too_large", "file exceeds the 5MB limit", 413),
error.error,
)
}
@Test
fun `415 unsupported_media_type is surfaced with its code`() = runBlocking {
backend.reply(
415,
"""{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}""",
)
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals("unsupported_media_type", (error.error as CyreneError.Server).code)
}
@Test
fun `401 stays a failure because the session itself is no longer valid`() = runBlocking {
backend.reply(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertTrue("实际 ${error.error}", error.error is CyreneError.Unauthorized)
}
@Test
fun `a relative url is a parse failure not something we would send as image`() = runBlocking {
// `chat.image` 只把 http(s):// 当 URL:裸路径会被当成 base64 载荷。
backend.reply(201, """{"data":{"url":"/api/v1/files/aa.jpg","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `a blank url is a parse failure instead of a silent empty image`() = runBlocking {
backend.reply(201, """{"data":{"url":"","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""")
val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `a 201 without data is a parse failure`() = runBlocking {
backend.reply(201, "{}")
val error = repository().uploadImage(jpeg, "image/jpeg").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().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
}
/** 记录每次请求的原始字节与头,按 `reply(...)` 回话的假后端。 */
private inner class FakeUploadBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
createContext("/api/v1/upload", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
/** 服务端生成的存储名:32 位小写 hex + 白名单扩展名。 */
val name = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"
val url: String get() = baseUrl + "api/v1/files/" + name
val requests = CopyOnWriteArrayList<RecordedRequest>()
@Volatile
private var status: Int = 201
@Volatile
private var body: String = ""
fun reply(status: Int, body: String) {
this.status = status
this.body = body
}
/** 线上的成功形状(`uploadResponse`):`size` 报的就是本次真正发出去的那段字节数。 */
fun successBody(mime: String = "image/jpeg"): String =
"""{"data":{"url":"$url","size":${jpeg.size},"mime":"$mime","name":"$name"}}"""
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(),
body = raw.toString(Charsets.ISO_8859_1),
)
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() {
runCatching { server.stop(0) }
}
}
private data class RecordedRequest(
val method: String,
val path: String,
val contentType: String,
val body: String,
)
}
@@ -0,0 +1,81 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 内联 vs 上传的阈值(纯函数)。
*
* 判据是**重编码后的字节数**:它同时决定「这次多走一次往返值不值」与「历史会背多重的 base64」。
* 阈值本身不是服务端限制——服务端的两个 5 MiB 上限离这里还有两个数量级,所以下面的不变量
* 才是重点:**上传分支必须够得着(不是死代码)、内联分支必须永远送得出去**。
*/
class ChatImageDeliveryTest {
@Test
fun `small images stay inline`() {
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(0))
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(1))
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(32 * 1024))
}
@Test
fun `the threshold is inclusive on the inline side`() {
assertEquals(
ImageDelivery.Inline,
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES),
)
assertEquals(
ImageDelivery.Upload,
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES + 1),
)
}
@Test
fun `the threshold is 64 KiB`() {
// 一个成本量级上的取整值:每次历史取数多 4/3 × B,64 KiB 时约 +85 KB/次(见下一条)。
assertEquals(64 * 1024, ChatImageDelivery.INLINE_MAX_BYTES)
}
@Test
fun `at the threshold one history fetch pays at least the number the docs claim`() {
// 类注释里「+85 KB」这个数的出处:base64 膨胀 4/3 后是 87384 字符。
assertEquals(87384, ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES))
assertTrue(ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES) > 85 * 1000)
}
@Test
fun `the upload branch is reachable but not mandatory for everything`() {
// 阈值得低于重编码出口的上限,否则「上传」是死代码;但也不能低到把一切都推去上传。
assertTrue(
"阈值得小于本端预算 ${ChatImageBudget.MAX_DECODED_BYTES},否则上传分支永远走不到",
ChatImageDelivery.INLINE_MAX_BYTES < ChatImageBudget.MAX_DECODED_BYTES,
)
assertTrue(
"阈值也不能小到把缩略图也推去上传",
ChatImageDelivery.INLINE_MAX_BYTES * 4 <= ChatImageBudget.MAX_DECODED_BYTES,
)
}
@Test
fun `the inline branch always fits the server cap by construction`() {
// 内联的字节上限就是本端预算:它的 base64 + 前缀余量必须仍在服务端字符上限内。
val worstChars = ChatImageBudget.base64Chars(ChatImageBudget.MAX_DECODED_BYTES) +
ChatImageBudget.PREFIX_ALLOWANCE_CHARS
assertTrue(ChatImageBudget.withinServerLimit(worstChars))
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
}
@Test
fun `the choice is about history weight, not about fitting under a cap`() {
// 重编码出口比服务端硬顶低 5 倍:所以「走上传」从来不是为了装得下,
// 而是为了让每一页历史 / 每一次 WS history 回放不再重传这段 base64。
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 5 <= ChatImageBudget.SERVER_MAX_DECODED_BYTES)
assertEquals(
ImageDelivery.Upload,
ChatImageDelivery.forPreparedBytes(ChatImageBudget.MAX_DECODED_BYTES),
)
}
}
@@ -0,0 +1,89 @@
package top.yeij.cyrene.ui.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
/**
* 上传失败的文案(纯函数)。
*
* 这里锚的是**行为承诺**,不是措辞本身:上传失败时这条消息**没有发出去**(文本与图一起
* 回到输入框),而且**绝不**退回内联 base64——退回就等于把「省下历史重量」当场作废,
* 还是在用户看不见的情况下换了一条语义不同的路。
*
* 两个服务端拒绝码对应的是「换一张图」而不是「重试」,必须与网络失败分开说。
*/
class ImageUploadFailureTest {
private fun server(code: String, message: String, status: Int = 400) =
CyreneException(CyreneError.Server(code, message, status))
@Test
fun `payload_too_large asks for a smaller image, not a retry`() {
val message = uploadFailureMessage(server("payload_too_large", "file exceeds the 5MB limit", 413))
assertEquals("图片超过服务端 5 MB 上限,请换一张更小的", message)
}
@Test
fun `unsupported_media_type says the format is refused`() {
val message = uploadFailureMessage(
server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415),
)
assertEquals("服务端不接受这张图片的格式,请换一张", message)
}
@Test
fun `a network failure names the step that failed and keeps the reason`() {
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
assertEquals("图片上传失败,网络不可用,请检查连接", message)
}
@Test
fun `a timeout keeps its own wording`() {
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
assertEquals("图片上传失败,连接超时,服务器没有响应", message)
}
@Test
fun `a server error keeps the server message instead of inventing one`() {
val message = uploadFailureMessage(server("upload_failed", "failed to store upload", 500))
assertEquals("图片上传失败,failed to store upload", message)
}
@Test
fun `an expired session is still an upload failure, never a silent success`() {
val message = uploadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")))
assertTrue(message, message.startsWith("图片上传失败"))
}
@Test
fun `a non Cyrene throwable still produces an actionable message`() {
assertEquals("图片上传失败,请重试", uploadFailureMessage(IllegalStateException("boom")))
assertEquals("图片上传失败,请重试", uploadFailureMessage(RuntimeException()))
}
@Test
fun `no failure path ever falls back to inlining the image`() {
// 文案里绝不能出现「改用内联 / 直接发送」这类暗示:回退内联正是要消灭的成本。
val messages = listOf(
uploadFailureMessage(server("payload_too_large", "x", 413)),
uploadFailureMessage(server("unsupported_media_type", "x", 415)),
uploadFailureMessage(server("upload_failed", "x", 500)),
uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.ConnectFailed))),
uploadFailureMessage(IllegalStateException()),
)
assertTrue(
messages.toString(),
messages.all { it.contains("换一张") || it.contains("重试") || it.contains("上传失败") },
)
}
}