- ① 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 全绿,判为瞬时;已记入账本,后续留意。
174 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|