package gateway import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 本文件覆盖 PATCH /api/v1/sessions/{id} 的两个 400 文案之别: // 「字段缺失」(title 与 pinned 都没给)与「字段在、值非法」 // (title 存在但 trim 后为空白)必须给出不同的说明——否则 // 空白标题读起来像「缺字段」,客户端会去补一个本就在的键。 // 同时钉住客户端依赖的既有语义:空串/仅空白**不能**清空标题 // (因此客户端不提供「恢复自动命名」入口)。 // patchSessionError 解出 PATCH /sessions/{id} 失败响应的 // {code,message},使断言针对客户端看到的文案本身。 func patchSessionError(t *testing.T, body []byte) (string, string) { t.Helper() var eobj struct { Code string `json:"code"` Message string `json:"message"` } if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil { t.Fatalf("decode error envelope: %v (body=%s)", err, body) } return eobj.Code, eobj.Message } // sessionTitle 读回列表里某会话的标题,用于断言标题未被改写。 func sessionTitle(t *testing.T, ts *httptest.Server, tok, id string) string { t.Helper() resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("list sessions status = %d, want 200 (body=%s)", resp.StatusCode, body) } var sessions []struct { ID string `json:"id"` Title string `json:"title"` } decodeDataSlice(t, body, &sessions) for _, s := range sessions { if s.ID == id { return s.Title } } t.Fatalf("session %s missing from list", id) return "" } // TestPatchSessionDistinguishesMissingFromBlank 断言两类 400 的文案互不相同 // 且各自说到点上:都缺字段时说明「至少需要 title 或 pinned」, // 只有 title 且为空白时说明「title 不得为空白」(不是 // 读起来像缺字段的 "title is required")。 func TestPatchSessionDistinguishesMissingFromBlank(t *testing.T) { t.Parallel() _, ts, jwt, _ := newHistoryEnv(t) tok := signAny(t, jwt, "acct", auth.RoleOwner) base := ts.URL + "/api/v1/sessions/" + history.MainSessionID // ① 两个字段都没给:文案必须点名两个可给的字段。 resp, body := doReq(t, http.MethodPatch, base, map[string]any{}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("empty body status = %d, want 400 (body=%s)", resp.StatusCode, body) } code, missingMsg := patchSessionError(t, body) if code != "bad_request" { t.Fatalf("empty body code = %q, want bad_request (body=%s)", code, body) } if !strings.Contains(missingMsg, "title") || !strings.Contains(missingMsg, "pinned") { t.Fatalf("empty body message = %q, want it to name both title and pinned", missingMsg) } t.Logf("字段缺失 → %s", body) // ② title 在但为空白:文案必须说「不得为空白」,且与 ① 不同。 for _, blank := range []string{"", " ", " \t\n "} { resp, body = doReq(t, http.MethodPatch, base, map[string]any{"title": blank}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("blank title %q status = %d, want 400 (body=%s)", blank, resp.StatusCode, body) } code, blankMsg := patchSessionError(t, body) if code != "bad_request" { t.Fatalf("blank title %q code = %q, want bad_request (body=%s)", blank, code, body) } if blankMsg != "title must not be blank" { t.Fatalf("blank title %q message = %q, want %q", blank, blankMsg, "title must not be blank") } if blankMsg == missingMsg { t.Fatalf("blank and missing share message %q, want the two cases distinguishable", blankMsg) } if strings.Contains(blankMsg, "is required") { t.Fatalf("blank title message = %q, want it not to read as a missing field", blankMsg) } t.Logf("字段在但空白 → %s", body) } // ③ 既存语义不变:空白 PATCH 不改标题(标题无法清空)。 resp, body = doReq(t, http.MethodPatch, base, map[string]any{"title": " 保留我 "}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("rename status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := sessionTitle(t, ts, tok, history.MainSessionID); got != "保留我" { t.Fatalf("title after rename = %q, want 保留我", got) } resp, body = doReq(t, http.MethodPatch, base, map[string]any{"title": ""}, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("clear attempt status = %d, want 400 (body=%s)", resp.StatusCode, body) } if got := sessionTitle(t, ts, tok, history.MainSessionID); got != "保留我" { t.Fatalf("title after clear attempt = %q, want it unchanged at 保留我", got) } // ④ 只给 pinned(无 title)仍然可用:区分文案不得收紧原有契约。 resp, body = doReq(t, http.MethodPatch, base, map[string]any{"pinned": true}, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("pin-only status = %d, want 200 (body=%s)", resp.StatusCode, body) } }