- 原状:网关与 vision 各有一份同值常量,且网关上限可由 CHAT_IMAGE_MAX_BYTES 调大 ⇒ 调大后 **网关照放、vision 拒绝**(ErrImageTooLarge),base64/URL 路径上「调大配置」失效 - 修法:网关改为**引用 vision 的常量**(`vision.MaxInlineImageBytes` / `InlineImagePrefixAllowance` / `MaxInlineImageChars`,import 方向与既有的 voice 同层、不违规);`chatImageLimitBytes` 语义= 「configured 生效但**钳在 vision 硬顶**」(只收紧不放宽);默认口径直接用 vision 常量(不再自算) - 复核:gateway 包测试绿(9 条 ChatImage,含默认逐字节同值、配 1 MiB 生效、配 8 MiB 被钳到硬顶且 同一串在 vision 侧也 ErrImageTooLarge ⇒ 两侧同一答案);**变异两点**(去掉钳位分支 → 断言红; 再清空解析循环 → 实测复现「配 2× 硬顶时网关 200 放行」)均已还原 - 登记:真要支持 > 5 MiB 需给 vision 可注入上限(并把 /upload 的 5 MiB 一起改);`.env.example`/`config.go` 注释/`api-reference` 仍写「上限=CHAT_IMAGE_MAX_BYTES」未提钳位,待同步
380 lines
17 KiB
Go
380 lines
17 KiB
Go
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"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/vision"
|
||
)
|
||
|
||
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
|
||
// 上限(默认与 POST /api/v1/upload 的 5 MiB 同口径 —— 也就是 vision 解码层的
|
||
// 硬顶,可用 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)
|
||
}
|
||
|
||
// TestChatImageDefaultLimitMatchesVisionHardCap 钉住「默认口径 = vision 解码层硬顶」
|
||
// 这条单一真源:默认上限、前缀余量与字符换算结果都必须与
|
||
// internal/runtime/multimodal/vision 的权威常量逐字节相等,且未配置(0 / 负数)时
|
||
// 落在同一个数上 —— 默认行为与引入该配置之前逐字节一致,同时两处口径不可能漂移。
|
||
func TestChatImageDefaultLimitMatchesVisionHardCap(t *testing.T) {
|
||
t.Parallel()
|
||
if maxChatImageBytes != vision.MaxInlineImageBytes {
|
||
t.Fatalf("默认上限 = %d, want vision 硬顶 %d", maxChatImageBytes, vision.MaxInlineImageBytes)
|
||
}
|
||
if chatImagePrefixAllowance != vision.InlineImagePrefixAllowance {
|
||
t.Fatalf("前缀余量 = %d, want %d", chatImagePrefixAllowance, vision.InlineImagePrefixAllowance)
|
||
}
|
||
if got := chatImageCharsFor(maxChatImageBytes); got != vision.MaxInlineImageChars {
|
||
t.Fatalf("默认字符上限 = %d, want vision.MaxInlineImageChars %d", got, vision.MaxInlineImageChars)
|
||
}
|
||
for _, configured := range []int{0, -1, -1 << 20} {
|
||
if got := chatImageLimitBytes(configured); got != vision.MaxInlineImageBytes {
|
||
t.Fatalf("chatImageLimitBytes(%d) = %d, want 默认(= vision 硬顶)%d",
|
||
configured, got, vision.MaxInlineImageBytes)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestChatImageConfiguredLimitClampedToVisionHardCap 钉住「配置只能收紧上限」:
|
||
// CHAT_IMAGE_MAX_BYTES 配成硬顶的 2 倍时,生效上限仍是 vision 硬顶(比硬顶只小一格的
|
||
// 图照样通过,多一个字符就被拒),且解码层对同一串给出的裁决一致 —— base64/URL 路径上
|
||
// 不再出现「网关照放、vision 拒」的自相矛盾。
|
||
// 变异反证:去掉 chatImageLimitBytes 的钳位,第二条断言里的超硬顶载荷会被放行(200)
|
||
// 并驱动 runner,本用例立刻红。
|
||
func TestChatImageConfiguredLimitClampedToVisionHardCap(t *testing.T) {
|
||
t.Parallel()
|
||
// 解析口径:配得比硬顶大(大 1 字节、2 倍、1 GiB)一律回到硬顶。
|
||
for _, configured := range []int{vision.MaxInlineImageBytes + 1, 2 * vision.MaxInlineImageBytes, 1 << 30} {
|
||
if got := chatImageLimitBytes(configured); got != vision.MaxInlineImageBytes {
|
||
t.Fatalf("chatImageLimitBytes(%d) = %d, want 硬顶 %d(配置不得放宽上限)",
|
||
configured, got, vision.MaxInlineImageBytes)
|
||
}
|
||
}
|
||
|
||
runner := &chatImageRecordingRunner{}
|
||
env := newEnv(t, runner, false)
|
||
env.srv.cfg.Server.ChatImageMaxBytes = 2 * vision.MaxInlineImageBytes
|
||
tok := env.token(t, auth.RoleOwner)
|
||
|
||
const prefix = "data:image/png;base64,"
|
||
// 比硬顶多一个字符:网关必须按硬顶拒掉它,而不是按配置放行。
|
||
over := prefix + strings.Repeat("A", vision.MaxInlineImageChars+1-len(prefix))
|
||
if len(over) != vision.MaxInlineImageChars+1 {
|
||
t.Fatalf("构造长度 = %d, want %d", len(over), vision.MaxInlineImageChars+1)
|
||
}
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
|
||
map[string]any{"sessionId": "web", "image": over}, tok)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("配到 2× 硬顶时仍须按硬顶拒,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) != 0 {
|
||
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
|
||
}
|
||
if _, _, err := vision.Resolve(context.Background(),
|
||
vision.ImageRef{Source: vision.SourceBase64, Data: over}, nil); !errors.Is(err, vision.ErrImageTooLarge) {
|
||
t.Fatalf("解码层对同一串的裁决 = %v, want ErrImageTooLarge(网关口径必须与解码层一致)", err)
|
||
}
|
||
|
||
// 恰好等于硬顶的图照常通过:钳位不得顺手把上限再收紧一格。
|
||
atCap := prefix + strings.Repeat("A", vision.MaxInlineImageChars-len(prefix))
|
||
if len(atCap) != vision.MaxInlineImageChars {
|
||
t.Fatalf("构造长度 = %d, want %d", len(atCap), vision.MaxInlineImageChars)
|
||
}
|
||
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
|
||
map[string]any{"sessionId": "web", "image": atCap}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("恰好等于硬顶的图必须通过,status = %d (body=%s)", resp.StatusCode, body)
|
||
}
|
||
if got := runner.images(); len(got) != 1 || got[0] != atCap {
|
||
t.Fatalf("到达 runner 的 image 与请求逐字节不一致:len=%d", len(got))
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|