- F5:PATCH /sessions/{id} 的 400 文案区分两类——都缺 → at least one of title or pinned is required;
存在但空白 → title must not be blank(此前空白也回 title is required,误导排查方向)。
「空串不能清空标题」的既有语义保持不变(用例显式钉住),新增 history_write_test.go 4 段断言
- E4:/auth/logout 的两种真实响应形状(单撤 {revoked} / 全撤 {revoked,revokedCount})+ 错误码;
/auth/refresh 补 role;登录响应与一次性接入段补 refreshToken(两份文档一致)
- E5:api-reference §3.2 补 pinned/unreadCount/lastMessage/personaId 与准确排序规则
(pinned DESC, (type='main') DESC, created_at ASC ⇒ 置顶的次级会话可排在主会话之前);
新增 §3.6a PATCH /sessions/{id}(请求体/错误码/部分更新/title 不可清空)与 §3.6b POST /sessions/{id}/read;
端点表补 read 行
- api-client 删掉「主会话恒在前」的错误断言(两处),PATCH 错误码文案同步
- DevDocs/02-development-constraints.md:上传上限 50MB → **硬 5 MiB**(常量、无配置项)
124 lines
5.0 KiB
Go
124 lines
5.0 KiB
Go
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)
|
||
}
|
||
}
|