Files
Cyrene_v2/internal/services/gateway/history_session_guard_test.go
AskaEth 902b6eecfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。

- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。

- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。

- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。

- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。

- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00

174 lines
6.9 KiB
Go

package gateway
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// 本文件钉住契约 v0.63 的 `GET /api/v1/sessions/{id}/messages` 语义(客户端登记,
// 2026-10-05):
//
// - **不存在 / 已删除**的会话 ⇒ `404 {error:{code:"session_not_found"}}`
// (与 `PATCH /sessions/{id}` / `POST /sessions/{id}/read` 对未知会话的口径
// 一致),客户端因此能把「当前会话已经不在了」与「这是一条空会话」分开;
// - **存在但没有消息** ⇒ 仍是 `200 {"data":[]}`(空会话绝不能被答成 404);
// - `?q=` / `after` / 参数校验的既有语义不变(400 仍先于 404 —— 请求形状错了
// 就是 400,与目标存不存在无关)。
// TestListMessagesUnknownSessionIs404 是本契约的主断言:一个从未存在过的会话 id
// 必须得到 404 + `session_not_found`,而不是「看起来像空会话」。
func TestListMessagesUnknownSessionIs404(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, id := range []string{"web:ffffffffffffffffffffffffffffffff", "web:never-existed"} {
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+id+"/messages", nil, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET %s/messages status = %d, want 404 (body=%s)", id, resp.StatusCode, body)
}
wantErrorMessage(t, body, "session_not_found")
}
}
// TestListMessagesDeletedSessionIs404 复现客户端的真实遭遇:一条被 DELETE 掉的次级
// 会话(连同它的消息)此后必须报「不在了」,而不是回一个空数组让客户端以为它只是
// 空的。
func TestListMessagesDeletedSessionIs404(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub session: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: sub, Role: history.RoleUser, Content: "临时验收会话", CreatedAt: 1000}); err != nil {
t.Fatalf("append: %v", err)
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 删除前:200 且带那条消息(契约的另一半 —— 活着的会话照旧)。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("删除前 status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var before []historyMessage
decodeDataSlice(t, body, &before)
if len(before) != 1 {
t.Fatalf("删除前 messages = %d 条, want 1", len(before))
}
if resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+sub, nil, tok); resp.StatusCode != http.StatusOK {
t.Fatalf("delete session status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages", nil, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("删除后 status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
wantErrorMessage(t, body, "session_not_found")
}
// TestListMessagesEmptySessionIs200Empty 是**回归**:真会话就是没有消息时,
// 必须仍然是 200 + 空数组 —— 契约只把「不存在」变成 404,绝不把「空会话」也卷进去。
// 主会话(不可删、永远存在)与刚建好的次级会话各测一次。
func TestListMessagesEmptySessionIs200Empty(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
sub, err := st.NewSubSession(context.Background(), "owner")
if err != nil {
t.Fatalf("new sub session: %v", err)
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, id := range []string{history.MainSessionID, sub} {
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+id+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("空会话 %s status = %d, want 200 (body=%s)", id, resp.StatusCode, body)
}
var page []historyMessage
decodeDataSlice(t, body, &page)
if len(page) != 0 {
t.Fatalf("空会话 %s messages = %+v, want 空数组", id, page)
}
}
}
// TestListMessagesUnknownSessionKeepsParamChecks 钉住判定顺序:请求形状错了就是
// 400(与目标会话存不存在无关),形状对了才轮到 404。`?q=` / `after` 的既有语义
// 因此在未知会话上也不会退化成一个含糊的 404。
func TestListMessagesUnknownSessionKeepsParamChecks(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/sessions/web:never-existed/messages"
for _, tc := range []struct {
name string
query string
want int
}{
{"非法 limit", "?limit=abc", http.StatusBadRequest},
{"limit 越界", "?limit=0", http.StatusBadRequest},
{"非法 before", "?before=abc", http.StatusBadRequest},
{"before 与 after 互斥", "?before=1000&after=500", http.StatusBadRequest},
{"q 与 after 互斥", "?q=x&after=500", http.StatusBadRequest},
{"形状合法 ⇒ 404", "?q=x", http.StatusNotFound},
{"after 形状合法 ⇒ 404", "?after=500", http.StatusNotFound},
} {
resp, body := doReq(t, http.MethodGet, base+tc.query, nil, tok)
if resp.StatusCode != tc.want {
t.Fatalf("%s: status = %d, want %d (body=%s)", tc.name, resp.StatusCode, tc.want, body)
}
}
}
// TestSessionMissingWithoutLookupSurfaceIsNotMissing 钉住「判不出来 ≠ 不存在」:
// 存储没有单会话读取面时(测试替身 / 未接库的窄存储),读路径保持旧的空集合语义
// —— 把一次本可成功的读答成 404 比答成空列表更糟。
func TestSessionMissingWithoutLookupSurfaceIsNotMissing(t *testing.T) {
t.Parallel()
hist := &fakeHistory{}
srv, err := New(cfgDefault(), Deps{Auth: auth.New(config.AuthConfig{JWTSecret: testSecret}), History: hist})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
t.Cleanup(srv.Close)
missing, err := srv.sessionMissing(context.Background(), "web:whatever")
if err != nil {
t.Fatalf("sessionMissing: %v", err)
}
if missing {
t.Fatal("没有存在性判据的存储不得被判成「会话不存在」")
}
}
// wantErrorMessage 断言错误信封的 code 与 message 都成形(客户端按 code 分支)。
func wantErrorMessage(t *testing.T, body []byte, code string) {
t.Helper()
env := decodeBody(t, body)
raw, ok := env["error"]
if !ok {
t.Fatalf("响应里没有 error 信封 (body=%s)", body)
}
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(raw, &eobj); err != nil {
t.Fatalf("解码 error 信封: %v (body=%s)", err, body)
}
if eobj.Code != code {
t.Fatalf("error.code = %q, want %q", eobj.Code, code)
}
if eobj.Message == "" {
t.Fatalf("error.message 为空 (body=%s)", body)
}
}