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) } }