Files
Cyrene_v2/internal/services/gateway/history_write_test.go
T
AskaEth 20d452a51a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway)+docs(api): F5 区分「字段缺失/值为空白」+ E4/E5 契约文档治理
- 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**(常量、无配置项)
2026-10-04 14:13:27 +08:00

124 lines
5.0 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 (
"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)
}
}