Files
Cyrene_v2/internal/services/gateway/rest_chat_image_test.go
T
AskaEth c96a7db7f1
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,config,web,docs): backthink 实时帧显示打扰原因 + 图片上限可配 + api-reference 端点表补齐
- 前端:`lib/wsParse.ts` 的 `parseBackthinkItem` 现在也读原始 `PushResult` 的 route/detail/bypassed(兼容
  camelCase),与历史 DTO 路径**同名同义**(空串 → undefined、未报告的 bool 不编成 false);
  Backthink 页 Notice 改为如实描述。证据=用后端两条路径的**真实序列化**喂同一个 PushResult 做逐字段比对
- 图片上限可配:`CHAT_IMAGE_MAX_BYTES`(默认 5242880 = 与 /upload 同口径;**未配置/0/负 一律用默认**,
  不存在「禁用上限」);删掉重复的字符常量,换算只留一份 `chatImageCharsFor`;补 3 条用例
  (默认值==upload 预算、配 1 MiB 真生效且只有合法那条驱动回合、非正值回落默认)
- `Docs/api-reference.md`:§1 端点表按 `routes.go` **补齐**(auth refresh/logout、upload、files、三支 audio、
  /admin/plugins/installed、/admin/llm/catalog、observability/logs、notifications、owner-profile、reminders),
  既有编号一律未动(保交叉引用);两张 `historyMessage` 字段表补 `personaId`(+ `clientMsgId`);
  修正 §8.1「主人档案保存仍 501」与实现不符(已真实实现)
- 复核:build/vet 绿 + gateway/config 包测试绿 + 前端 tsc 0 错 + vite build 成功
- 登记:§1 各表的编号跨表重复(不重编号以保交叉引用);`admin_plugins.go:217` 注释写错鉴权档(未改);
  §6.4 的 WS 事件帧表仍未补全
2026-10-04 16:24:36 +08:00

297 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
// 上限(默认与 POST /api/v1/upload 的 5 MiB 同口径,可用 CHAT_IMAGE_MAX_BYTES
// 配置覆盖;≤0 = 用默认),超限回 400 + image_too_large 的明确错误,而不是把
// 几 MB 的 base64 原样吃下、落库、再被每页历史重传一遍。
// chatImageRecordingRunner 记录每次 Run 看到的完整消息(不只是 sessionID),
// 因此既能断言「超限的轮次从未被驱动」,也能逐字节比对正常小图的图片载荷。
type chatImageRecordingRunner struct {
calls [][]model.Message
res *loop.Result
}
func (r *chatImageRecordingRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
r.calls = append(r.calls, msgs)
if r.res != nil {
return r.res, nil
}
return &loop.Result{Text: "收到", Steps: 1, StopReason: "stop"}, nil
}
// images 返回每次 Run 中首个图片内容的 ImageURL。
func (r *chatImageRecordingRunner) images() []string {
out := make([]string, 0, len(r.calls))
for _, msgs := range r.calls {
for _, m := range msgs {
for _, c := range m.Content {
if c.Type == model.ContentTypeImage {
out = append(out, c.ImageURL)
}
}
}
}
return out
}
// oversizeImage 返回一个长度恰为「默认上限换算出的字符数 + 1」的 image 串(内容
// 本身是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。
func oversizeImage() string {
return strings.Repeat("A", chatImageCharsFor(maxChatImageBytes)+1)
}
// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:**未配置**时生效上限
// 必须与 POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成
// base64 字符串长度后既**装得下**一张满额的 5 MiB 图,也不留下荒谬的余量。
// 这条不变量让「有人改了上限或改歪了膨胀系数」在测试里立刻暴露(反证)。
func TestChatImageLimitMatchesUploadBudget(t *testing.T) {
t.Parallel()
if maxChatImageBytes != maxUploadBytes {
t.Fatalf("maxChatImageBytes = %d, want %d (与 POST /upload 同口径)", maxChatImageBytes, maxUploadBytes)
}
// 配置的默认值(env 标签)必须就是 5 MiB:进程环境与 .env 都没给时,网关
// 拿到的正是这个数。这里直接读标签而不是去 Load 配置,是为了不受运行环境
// 里可能存在的同名环境变量影响(那反而会让断言失真)。
field, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName("ChatImageMaxBytes")
if !ok {
t.Fatal("config.ServerConfig 缺 ChatImageMaxBytes 字段(配置契约已断)")
}
if got := field.Tag.Get("default"); got != strconv.Itoa(maxChatImageBytes) {
t.Fatalf("CHAT_IMAGE_MAX_BYTES 的默认值 = %q, want %d", got, maxChatImageBytes)
}
// 未配置(零值)/ 0 / 负数一律解析为默认值 —— 默认行为与引入该配置之前逐字节一致。
for _, configured := range []int{0, -1, -1 << 20} {
if got := chatImageLimitBytes(configured); got != maxChatImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 默认 %d", configured, got, maxChatImageBytes)
}
}
chars := chatImageCharsFor(chatImageLimitBytes(0))
// 上限必须装得下一张满额图:base64 长度对应的解码字节数 ≥ 预算。
if got := chars / 4 * 3; got < maxChatImageBytes {
t.Fatalf("字符上限只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes)
}
// 余量只能是 data: 前缀的余量,不能顺带塞进一整张图。
if chars > ((maxChatImageBytes+2)/3)*4+4096 {
t.Fatalf("字符上限 = %d 的余量过大(前缀余量应 ≤4096)", chars)
}
}
// TestChatImageSizeBoundary 用极小上限直接钉住比较运算符与错误载荷:恰好
// 等于上限通过,多一个字符就 400 + image_too_large。若判定被改成 >= / <
// (即有人「顺手」把边界挪一位),这里立刻红。
func TestChatImageSizeBoundary(t *testing.T) {
t.Parallel()
const limit = 8
if err := checkChatImageSize(strings.Repeat("A", limit), limit); err != nil {
t.Fatalf("恰好等于上限必须通过,得到 %v", err)
}
err := checkChatImageSize(strings.Repeat("A", limit+1), limit)
if err == nil {
t.Fatal("超过上限一个字符必须报错")
}
if !errors.Is(err, apperr.ErrBadRequest) {
t.Fatalf("错误必须 wrap ErrBadRequest(400),得到 %v", err)
}
var target *apperr.AppError
if !errors.As(err, &target) || target.Code != chatImageTooLargeCode {
t.Fatalf("错误码必须为 %q,得到 %v", chatImageTooLargeCode, err)
}
if got := apperr.HTTPStatus(err); got != http.StatusBadRequest {
t.Fatalf("HTTP 状态 = %d, want 400", got)
}
}
// TestChatImageTooLargeRejected 是主用例:超限的 image 必须 400 +
// image_too_large,且**从未驱动过任何回合** —— 也就不会进模型、不会落库、
// 不会被历史回放重传。变异反证:把入口的校验删掉,这条测试会因为 turn 被
// 驱动而红。
func TestChatImageTooLargeRejected(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage()}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
code, msg := chatImageErrorOf(t, body)
if code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if !strings.Contains(msg, "upload") || !strings.Contains(msg, "/api/v1/upload") {
t.Fatalf("错误文案必须指路 POST /api/v1/upload,得到 %q", msg)
}
if strings.Contains(msg, "AAAA") {
t.Fatalf("错误文案不得回显图片内容:%q", msg)
}
if len(runner.calls) != 0 {
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
// TestChatImageTooLargeRejectedOnStreamPath 覆盖 REST 的 stream 分支:它会把
// 该轮转发给 ws.chat,转发前必须同样被拒(否则流式成为绕开上限的后门)。
func TestChatImageTooLargeRejectedOnStreamPath(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage(), "stream": true}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if len(runner.calls) != 0 {
t.Fatalf("超限的 stream 请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
// TestChatImageAtLimitAccepted 是边界:长度恰好等于**默认**上限的 image 照常通过
// (合法客户端在 5 MiB 整幅图上绝不能吃到误拒)。
func TestChatImageAtLimitAccepted(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
limit := chatImageCharsFor(maxChatImageBytes)
image := "data:image/png;base64," + strings.Repeat("A", limit-len("data:image/png;base64,"))
if len(image) != limit {
t.Fatalf("构造长度 = %d, want %d", len(image), limit)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": image}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := runner.images(); len(got) != 1 || got[0] != image {
t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got))
}
}
// TestChatImageConfiguredLimitTaken 钉住「配置真的生效」(G3 遗留:上限不再硬编码):
// 把 CHAT_IMAGE_MAX_BYTES 配成 1 MiB 后,恰好 1 MiB 的图仍然通过,而只多一个字符就
// 被 400 image_too_large 拒绝 —— 既证明配置走的是同一条入口校验,也证明上限确实变小了
// (第二个断言若仍按 5 MiB 算,会因远未到默认上限而被放行、测试即红)。
func TestChatImageConfiguredLimitTaken(t *testing.T) {
t.Parallel()
const configured = 1 << 20
limit := chatImageCharsFor(configured)
if limit >= chatImageCharsFor(maxChatImageBytes) {
t.Fatal("1 MiB 的字符上限不应 ≥ 默认 5 MiB 的上限(本用例失去了区分力)")
}
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
// 上限在每次请求时从配置读取,因此这里直接钉住该环境的配置值。
env.srv.cfg.Server.ChatImageMaxBytes = configured
tok := env.token(t, auth.RoleOwner)
atLimit := "data:image/png;base64," + strings.Repeat("A", limit-len("data:image/png;base64,"))
if len(atLimit) != limit {
t.Fatalf("构造长度 = %d, want %d", len(atLimit), limit)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atLimit}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("恰好 1 MiB 的图必须通过,status = %d (body=%s)", resp.StatusCode, body)
}
// 多一个字符即超限:默认 5 MiB 口径下它会通过,因此这条断言恰好区分了配置是否生效。
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atLimit + "A"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("超过配置上限一个字符必须 400,status = %d (body=%s)", resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
// 两次请求里只有第一条(合法那条)驱动了回合。
if len(runner.calls) != 1 {
t.Fatalf("turn 数 = %d, want 1", len(runner.calls))
}
}
// TestChatImageNonPositiveConfigUsesDefault 在 HTTP 入口钉住「0 / 负数 = 用默认」的语义:
// 配置成 0(或负数)时,超出**默认** 5 MiB 上限一个字符的图仍被拒 —— 上限没有被解成
// 「无限」,默认行为与引入该配置之前逐字节一致。
func TestChatImageNonPositiveConfigUsesDefault(t *testing.T) {
t.Parallel()
for _, configured := range []int{0, -1} {
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
env.srv.cfg.Server.ChatImageMaxBytes = configured
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage()}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("配置 %d 时超出默认上限的图必须 400,status = %d (body=%s)", configured, resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if len(runner.calls) != 0 {
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
}
// TestChatSmallImageUnchanged 锁定「正常小图行为与今天逐字节一致」:小
// data: URI 原样进入图片内容部分,文本与图片两部分都在。
func TestChatSmallImageUnchanged(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
image := "data:image/png;base64,iVBORw0KGgo="
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "text": "看看这张", "image": image}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if len(runner.calls) != 1 {
t.Fatalf("turn 数 = %d, want 1", len(runner.calls))
}
msgs := runner.calls[0]
if len(msgs) != 1 {
t.Fatalf("消息数 = %d, want 1", len(msgs))
}
if len(msgs[0].Content) != 2 || msgs[0].Content[0].Text != "看看这张" ||
msgs[0].Content[1].Type != model.ContentTypeImage || msgs[0].Content[1].ImageURL != image {
t.Fatalf("图片回合内容变了:%+v", msgs[0].Content)
}
}
// chatImageErrorOf 取出 {error:{code,message}} 的 code 与 message。
func chatImageErrorOf(t *testing.T, body []byte) (code, message string) {
t.Helper()
env := decodeBody(t, body)
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(env["error"], &eobj); err != nil {
t.Fatalf("error envelope: %v (body=%s)", err, body)
}
return eobj.Code, eobj.Message
}