Files
Cyrene_v2/internal/services/gateway/history_messages_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

336 lines
14 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 (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// seedFullMessage 经真实存储追加 m 并返回已存消息
// (含其生成的 id),使 HTTP 测试能定位到具体某条消息。
func seedFullMessage(t *testing.T, st *history.Store, m history.Message) history.Message {
t.Helper()
got, err := st.AppendFull(context.Background(), m)
if err != nil {
t.Fatalf("append %q: %v", m.Content, err)
}
return got
}
// TestHistorySearchMessagesHTTP 覆盖 D2:会话作用域的 ?q= 搜索命中、分页、
// 空 q 回退、输入校验以及与其他会话的隔离。
func TestHistorySearchMessagesHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
ctx := context.Background()
for _, m := range []history.Message{
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今天天气不错", CreatedAt: 1000},
{SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "嗯,适合出门", CreatedAt: 1500},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气要变凉了", CreatedAt: 2000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气又热了", CreatedAt: 3000},
{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "天气终于晴了", CreatedAt: 4000},
} {
if err := st.Append(ctx, m); err != nil {
t.Fatalf("seed %q: %v", m.Content, err)
}
}
// 另一个(子)会话中的匹配消息绝不得出现在主会话搜索中。
sub, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("new sub: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: sub, Role: history.RoleUser, Content: "天气 sub", CreatedAt: 5000}); err != nil {
t.Fatalf("seed sub: %v", err)
}
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// 最新优先的一页,2 条匹配命中。
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("search status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var page []historyMessage
decodeDataSlice(t, body, &page)
if len(page) != 2 || page[0].CreatedAt != 4000 || page[1].CreatedAt != 3000 {
t.Fatalf("search page = %+v, want [4000 3000]", page)
}
if page[0].SessionID != history.MainSessionID {
t.Fatalf("search leaked session %q", page[0].SessionID)
}
// before 游标翻到更旧的命中。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&before=3000&limit=2", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("search older status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var older []historyMessage
decodeDataSlice(t, body, &older)
if len(older) != 2 || older[0].CreatedAt != 2000 || older[1].CreatedAt != 1000 {
t.Fatalf("search older = %+v, want [2000 1000]", older)
}
// 子会话的搜索只看到自己的命中。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages?q=天气", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("sub search status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var subHits []historyMessage
decodeDataSlice(t, body, &subHits)
if len(subHits) != 1 || subHits[0].SessionID != sub {
t.Fatalf("sub search = %+v, want only the sub hit", subHits)
}
// 空 q 回退到普通分页(全部消息)。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=&limit=10", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty q status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var all []historyMessage
decodeDataSlice(t, body, &all)
if len(all) != 5 {
t.Fatalf("empty q = %d messages, want 5", len(all))
}
// q 与 after 互斥,且非法 limit 仍是 400。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&after=1000", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("q+after status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages?q=天气&limit=abc", nil, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("bad limit status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
}
// TestHistoryPatchAndDeleteMessageHTTP 覆盖 D3:编辑与删除单条消息、
// 输入边界、404 与 RBAC。
func TestHistoryPatchAndDeleteMessageHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, st := newHistoryEnv(t)
msg := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "原文", CreatedAt: 1000})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
base := ts.URL + "/api/v1/messages/" + msg.ID
// 编辑 content;role/时间戳/会话保持不变并回显。
resp, body := doReq(t, http.MethodPatch, base, map[string]any{"content": "改后的正文"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var updated historyMessage
if err := json.Unmarshal(decodeBody(t, body)["data"], &updated); err != nil {
t.Fatalf("decode patch data: %v (body=%s)", err, body)
}
if updated.ID != msg.ID || updated.Content != "改后的正文" || updated.Role != history.RoleUser || updated.CreatedAt != 1000 {
t.Fatalf("updated = %+v, want content edited with role/ts preserved", updated)
}
// 该编辑已被持久化。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var listed []historyMessage
decodeDataSlice(t, body, &listed)
if len(listed) != 1 || listed[0].Content != "改后的正文" {
t.Fatalf("listed = %+v, want the edited content", listed)
}
// 非法编辑:空/仅空白、字段缺失、过长、未知 id。
for _, tc := range []struct {
name string
body any
want int
}{
{"empty", map[string]any{"content": ""}, http.StatusBadRequest},
{"blank", map[string]any{"content": " "}, http.StatusBadRequest},
{"missing", map[string]any{}, http.StatusBadRequest},
{"too-long", map[string]any{"content": strings.Repeat("字", maxMessageContentRunes+1)}, http.StatusBadRequest},
} {
resp, body = doReq(t, http.MethodPatch, base, tc.body, tok)
if resp.StatusCode != tc.want {
t.Fatalf("patch %s status = %d, want %d (body=%s)", tc.name, resp.StatusCode, tc.want, body)
}
wantErrorShape(t, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/messages/missing", map[string]any{"content": "x"}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("patch unknown status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// 删除该消息,再次删除则是 404。
resp, body = doReq(t, http.MethodDelete, base, nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var deleted struct {
Deleted string `json:"deleted"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &deleted); err != nil {
t.Fatalf("decode delete data: %v (body=%s)", err, body)
}
if deleted.Deleted != msg.ID {
t.Fatalf("deleted = %q, want %q", deleted.Deleted, msg.ID)
}
resp, body = doReq(t, http.MethodDelete, base, nil, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("delete again status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
// viewer 不能编辑或删除(owner/admin 写)。
other := seedFullMessage(t, st, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "x", CreatedAt: 2000})
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
for _, method := range []string{http.MethodPatch, http.MethodDelete} {
resp, body = doReq(t, method, ts.URL+"/api/v1/messages/"+other.ID, map[string]any{"content": "nope"}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer %s status = %d, want 403 (body=%s)", method, resp.StatusCode, body)
}
}
}
// TestHistoryMessagesNotWiredHTTP 断言未注入 HistoryStore 时 D2/D3 端点
// 返回明确的 501。
func TestHistoryMessagesNotWiredHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, tc := range []struct {
method, url string
}{
{http.MethodGet, "/api/v1/sessions/web/messages?q=x"},
{http.MethodPatch, "/api/v1/messages/x"},
{http.MethodDelete, "/api/v1/messages/x"},
} {
resp, body := doReq(t, tc.method, ts.URL+tc.url, map[string]any{"content": "x"}, tok)
if resp.StatusCode != http.StatusNotImplemented {
t.Fatalf("%s %s status = %d, want 501 (body=%s)", tc.method, tc.url, resp.StatusCode, body)
}
if !strings.Contains(string(body), "not_implemented") {
t.Fatalf("%s %s body = %s, want not_implemented", tc.method, tc.url, body)
}
}
}
// TestHistoryPatchSessionPinnedHTTP 覆盖 D5:置顶/取消置顶改变 GET /sessions 顺序,
// 响应只回显被改动的字段,且 A4 的标题 PATCH 仍然可用。
func TestHistoryPatchSessionPinnedHTTP(t *testing.T) {
t.Parallel()
_, ts, jwt, _ := newHistoryEnv(t)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
newSub := func() string {
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var created struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &created); err != nil {
t.Fatalf("decode create: %v (body=%s)", err, body)
}
return created.SessionID
}
first, second := newSub(), newSub()
sessionOrder := func() []string {
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
ids := make([]string, 0, len(sessions))
for _, s := range sessions {
ids = append(ids, s.ID)
}
return ids
}
assertOrder := func(want []string) {
t.Helper()
got := sessionOrder()
if len(got) != len(want) {
t.Fatalf("order = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
}
assertOrder([]string{history.MainSessionID, first, second})
// 置顶最新的子会话:它会浮到主会话之上,且响应只回显
// 被置顶的字段。
resp, body := doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("pin status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var pinned struct {
ID string `json:"id"`
Pinned *bool `json:"pinned"`
Title string `json:"title"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &pinned); err != nil {
t.Fatalf("decode pin: %v (body=%s)", err, body)
}
if pinned.ID != second || pinned.Pinned == nil || !*pinned.Pinned || pinned.Title != "" {
t.Fatalf("pin data = %+v, want id+pinned:true only", pinned)
}
assertOrder([]string{second, history.MainSessionID, first})
// GET /sessions 报告置顶标志。
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var sessions []historySessionSummary
decodeDataSlice(t, body, &sessions)
if !sessions[0].Pinned || sessions[0].ID != second {
t.Fatalf("sessions[0] = %+v, want pinned sub %s", sessions[0], second)
}
// 标题 PATCH 仍然可用,并保持其 {id,title} 响应形状。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"title": " 置顶会话 "}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("title patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var renamed struct {
ID string `json:"id"`
Title string `json:"title"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &renamed); err != nil {
t.Fatalf("decode title patch: %v (body=%s)", err, body)
}
if renamed.ID != second || renamed.Title != "置顶会话" {
t.Fatalf("title patch = %+v, want 置顶会话", renamed)
}
// 取消置顶恢复基线顺序。
resp, _ = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": false}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("unpin status = %d, want 200", resp.StatusCode)
}
assertOrder([]string{history.MainSessionID, first, second})
// 两个字段都不给 → 400;未知 id → 404;viewer → 403。
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("empty patch status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/web:missing", map[string]any{"pinned": true}, tok)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("unknown pin status = %d, want 404 (body=%s)", resp.StatusCode, body)
}
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
resp, body = doReq(t, http.MethodPatch, ts.URL+"/api/v1/sessions/"+second, map[string]any{"pinned": true}, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer pin status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
}