fix(gateway)+docs(api): F5 区分「字段缺失/值为空白」+ E4/E5 契约文档治理
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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**(常量、无配置项)
This commit is contained in:
AskaEth committed 2026-10-04 14:13:27 +08:00
1 parent 5fb0cf5e19
commit 20d452a51a
4 files changed
+198 -20

No files matched your search

+9 -4
View File
@@ -42,8 +42,13 @@ const maxSessionTitleRunes = 80
// handlePatchSession 更新持久化会话的显示元数据 —— 其标题
// (A4)与置顶标志(D5)。二者都是元数据:与 DELETE 不同,它们
// 允许作用于固定的主会话。两个字段都不携带的请求返回 400,
// 空/仅空白或过长的标题亦然,未知会话 id(照常 404/500)。
// 允许作用于固定的主会话。未知会话 id 照常 404/500。
//
// 两种 400 刻意用不同文案区分「缺字段」与「字段在、值非法」:
// title 与 pinned 都没给 → 「至少需要 title 或 pinned」;
// 给了 title 但 trim 后为空白 → 「title 不得为空白」;过长 → 明确的上限提示。
// title 的空串/仅空白**没有**清空语义(不回落自动命名),
// 因而客户端不提供「恢复自动命名」入口;要换名只能给非空白标题。
//
// 携带 personaId 键的请求体对任何取值都返回 400,且该检查
// 在会话被查找之前执行:会话角色在创建时固定(design/22
@@ -74,14 +79,14 @@ func (s *Server) handlePatchSession(w http.ResponseWriter, r *http.Request) {
return
}
if req.Title == nil && req.Pinned == nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title or pinned is required"))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "at least one of title or pinned is required"))
return
}
resp := map[string]any{"id": id}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title is required"))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "title must not be blank"))
return
}
if utf8.RuneCountInString(title) > maxSessionTitleRunes {
@@ -0,0 +1,123 @@
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)
}
}